From 9589bda8c636d939ac83df0ff2852394829e193e Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 1 Sep 2026 15:34:08 -0400 Subject: [PATCH 01/15] feat: support the JSignPdf 3.x CLI and keep the password out of argv Signed-off-by: YvesCesar --- example/index.php | 1 + src/Runtime/JSignPdfRuntimeService.php | 48 +++-- src/Sign/JSignParam.php | 10 +- src/Sign/JSignService.php | 51 ++++- tests/JSignPDFTest.php | 194 ++++++++++++++++++- tests/Runtime/JSignPdfRuntimeServiceTest.php | 192 ++++++++++++++++++ 6 files changed, 466 insertions(+), 30 deletions(-) create mode 100644 tests/Runtime/JSignPdfRuntimeServiceTest.php diff --git a/example/index.php b/example/index.php index dca3118..5492430 100644 --- a/example/index.php +++ b/example/index.php @@ -30,6 +30,7 @@ $param->setCertificate($pfxCertificateContent); $param->setPdf(file_get_contents(__DIR__ . '/../tests/resources/pdf-test.pdf')); $param->setPassword($password); +$param->setJSignParameters('-kst PKCS12 --overwrite'); $jSignPdf = new JSignPDF($param); $fileSigned = $jSignPdf->sign(); diff --git a/src/Runtime/JSignPdfRuntimeService.php b/src/Runtime/JSignPdfRuntimeService.php index 17c5958..894ea6d 100644 --- a/src/Runtime/JSignPdfRuntimeService.php +++ b/src/Runtime/JSignPdfRuntimeService.php @@ -17,24 +17,21 @@ public function getPath(JSignParam $params): string $downloadUrl = $params->getJSignPdfDownloadUrl(); if ($jsignPdfPath && !$downloadUrl) { - if (file_exists($jsignPdfPath)) { + if (self::isInstalled($jsignPdfPath)) { return $jsignPdfPath; } throw new InvalidArgumentException('Jar of JSignPDF not found on path: '. $jsignPdfPath); } if ($downloadUrl && $jsignPdfPath) { - $baseDir = preg_replace('/\/JSignPdf.jar$/', '', $jsignPdfPath); - if (!is_string($baseDir)) { - throw new InvalidArgumentException('Invalid JsignParamPath'); - } + $baseDir = self::baseDir($jsignPdfPath); if (!is_dir($baseDir)) { $ok = mkdir($baseDir, 0755, true); if ($ok === false) { throw new InvalidArgumentException('The JSignPdf base dir cannot be created: '. $baseDir); } } - if (!file_exists($jsignPdfPath) || !self::validateVersion($params)) { + if (!self::isInstalled($jsignPdfPath) || !self::validateVersion($params)) { self::downloadAndExtract($params); } return $jsignPdfPath; @@ -43,10 +40,24 @@ public function getPath(JSignParam $params): string throw new InvalidArgumentException('Java not found.'); } + public static function baseDir(string $jsignPdfPath): string + { + $baseDir = preg_replace('/\/JSignPdf.jar$/', '', $jsignPdfPath); + if (!is_string($baseDir)) { + throw new InvalidArgumentException('Invalid JsignParamPath'); + } + return $baseDir; + } + + private static function isInstalled(string $jsignPdfPath): bool + { + return file_exists($jsignPdfPath) || is_dir(self::baseDir($jsignPdfPath) . '/lib'); + } + private function validateVersion(JSignParam $params): bool { - $jsignPdfPath = $params->getjSignPdfJarPath(); - $versionCacheFile = $jsignPdfPath . '/.jsignpdf_version_' . basename($params->getJSignPdfDownloadUrl()); + $baseDir = self::baseDir($params->getjSignPdfJarPath()); + $versionCacheFile = $baseDir . '/.jsignpdf_version_' . basename($params->getJSignPdfDownloadUrl()); return file_exists($versionCacheFile); } @@ -55,10 +66,7 @@ private function downloadAndExtract(JSignParam $params): void $jsignPdfPath = $params->getjSignPdfJarPath(); $url = $params->getJSignPdfDownloadUrl(); - $baseDir = preg_replace('/\/JSignPdf.jar$/', '', $jsignPdfPath); - if (!is_string($baseDir)) { - throw new InvalidArgumentException('Invalid JsignParamPath'); - } + $baseDir = self::baseDir($jsignPdfPath); if (!is_dir($baseDir)) { $ok = mkdir($baseDir, 0755, true); if (!$ok) { @@ -74,16 +82,20 @@ private function downloadAndExtract(JSignParam $params): void if ($ok !== true) { throw new InvalidArgumentException('The file ' . $baseDir . '/jsignpdf.zip cannot be extracted'); } - $ok = $z->extractTo(pathto: $baseDir, files: [$z->getNameIndex(0) . 'JSignPdf.jar']); + $rootDirInsideZip = $z->getNameIndex(0); + if (!is_string($rootDirInsideZip)) { + throw new InvalidArgumentException('The file ' . $baseDir . '/jsignpdf.zip is empty'); + } + $ok = $z->extractTo($baseDir); if ($ok !== true) { - throw new InvalidArgumentException('JSignPdf.jar not found inside path: ' . $z->getNameIndex(0) . 'JSignPdf.jar'); + throw new InvalidArgumentException('The file ' . $baseDir . '/jsignpdf.zip cannot be extracted'); } - @exec('mv ' . escapeshellarg($baseDir . '/'. $z->getNameIndex(0)) . '/JSignPdf.jar ' . escapeshellarg($baseDir)); - @exec('rm -rf ' . escapeshellarg($baseDir . '/'. $z->getNameIndex(0))); + @exec('mv ' . escapeshellarg($baseDir . '/'. $rootDirInsideZip) . '/* ' . escapeshellarg($baseDir)); + @exec('rm -rf ' . escapeshellarg($baseDir . '/'. $rootDirInsideZip)); @exec('rm -f ' . escapeshellarg($baseDir) . '/.jsignpdf_version_*'); unlink($baseDir . '/jsignpdf.zip'); - if (!file_exists($baseDir . '/JSignPdf.jar')) { - throw new RuntimeException('Java binary not found at: ' . $baseDir . '/bin/java'); + if (!self::isInstalled($jsignPdfPath)) { + throw new RuntimeException('JSignPdf not found at: ' . $baseDir); } touch($baseDir . '/.jsignpdf_version_' . basename($url)); } diff --git a/src/Sign/JSignParam.php b/src/Sign/JSignParam.php index 52e0c77..95e4164 100644 --- a/src/Sign/JSignParam.php +++ b/src/Sign/JSignParam.php @@ -19,7 +19,7 @@ class JSignParam private bool $isOutputTypeBase64 = false; private string $jSignPdfJarPath = ''; private string $javaDownloadUrl = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.8_9.tar.gz'; - private string $jSignPdfDownloadUrl = 'https://github.com/intoolswetrust/jsignpdf/releases/download/JSignPdf_2_3_0/jsignpdf-2.3.0.zip'; + private string $jSignPdfDownloadUrl = 'https://github.com/intoolswetrust/jsignpdf/releases/download/JSignPdf_3_1_0/jsignpdf-3.1.0-minimal.zip'; public function __construct() { @@ -83,8 +83,14 @@ public function getJSignParameters(): string return $this->JSignParameters; } - public function setJSignParameters(string $JSignParameters): self + /** + * @param string|list $JSignParameters + */ + public function setJSignParameters(string|array $JSignParameters): self { + if (is_array($JSignParameters)) { + $JSignParameters = implode(' ', array_map('escapeshellarg', $JSignParameters)); + } $this->JSignParameters = $JSignParameters; return $this; } diff --git a/src/Sign/JSignService.php b/src/Sign/JSignService.php index ce92851..39aa811 100644 --- a/src/Sign/JSignService.php +++ b/src/Sign/JSignService.php @@ -14,6 +14,8 @@ */ class JSignService { + private const MAIN_CLASS = 'com.intoolswetrust.jsignpdf.Bootstrap'; + private JSignFileService $fileService; public function __construct() @@ -27,7 +29,7 @@ public function sign(JSignParam $params): string $this->validation($params); $commandSign = $this->commandSign($params); - exec($commandSign, $output); + $output = $this->execWithPasswordOnStdin($commandSign, $params->getPassword()); $out = json_encode($output); if ($out === false) { @@ -79,11 +81,10 @@ private function repackCertificateIfPasswordIsUnicode( public function getVersion(JSignParam $params): string { - $java = $this->javaCommand($params); - $jSignPdf = $this->getjSignPdfJarPath($params); - $jSignPdf = $params->getjSignPdfJarPath(); + $java = escapeshellarg($this->javaCommand($params)); + $jSignPdf = $this->jSignPdfInvocation($params); - $command = "$java -jar $jSignPdf --version 2>&1"; + $command = "$java $jSignPdf --version 2>&1"; exec($command, $output); $lastRow = end($output); if (empty($output) || strpos($lastRow, 'version') === false) { @@ -133,11 +134,43 @@ private function storeTempFiles(JSignParam $params): array private function commandSign(JSignParam $params): string { list($pdf, $certificate) = $this->storeTempFiles($params); - $java = $this->javaCommand($params); - $jSignPdf = $this->getjSignPdfJarPath($params); + $java = escapeshellarg($this->javaCommand($params)); + $jSignPdf = $this->jSignPdfInvocation($params); + $pdf = escapeshellarg($pdf); + $certificate = escapeshellarg($certificate); + $pathPdfSigned = escapeshellarg($params->getPathPdfSigned()); + + return "$java -Duser.language=en $jSignPdf $pdf -ksf $certificate --enable-stdin-passwords -ksp - {$params->getJSignParameters()} -d $pathPdfSigned 2>&1"; + } + + private function jSignPdfInvocation(JSignParam $params): string + { + $jSignPdfPath = $this->getjSignPdfJarPath($params); + $libDir = JSignPdfRuntimeService::baseDir($jSignPdfPath) . '/lib'; + if (!is_file($jSignPdfPath) && is_dir($libDir)) { + return '-classpath ' . escapeshellarg($libDir . '/*') . ' ' . self::MAIN_CLASS; + } + return '-jar ' . escapeshellarg($jSignPdfPath); + } + + private function execWithPasswordOnStdin(string $command, string $password): array + { + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + ]; + $pipes = []; + $process = proc_open($command, $descriptors, $pipes); + if (!is_resource($process)) { + throw new Exception('Error to sign PDF.'); + } + fwrite($pipes[0], $password . PHP_EOL); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + proc_close($process); - $password = escapeshellarg($params->getPassword()); - return "$java -Duser.language=en -jar $jSignPdf $pdf -ksf $certificate -ksp {$password} {$params->getJSignParameters()} -d {$params->getPathPdfSigned()} 2>&1"; + return explode(PHP_EOL, rtrim((string) $output, PHP_EOL)); } private function javaCommand(JSignParam $params): string diff --git a/tests/JSignPDFTest.php b/tests/JSignPDFTest.php index 5450be9..d3f174f 100644 --- a/tests/JSignPDFTest.php +++ b/tests/JSignPDFTest.php @@ -12,10 +12,32 @@ function exec(string $command, ?array &$output = null, ?int &$return_var = null) return \exec($command, $output, $return_var); } +function proc_open(string $command, array $descriptor_spec, ?array &$pipes) +{ + global $mockExec, $mockProcCommand, $mockProcStdinFile; + if (!$mockExec) { + return \proc_open($command, $descriptor_spec, $pipes); + } + $mockProcCommand = $command; + $mockProcStdinFile = tempnam(sys_get_temp_dir(), 'jsignpdf_stdin_'); + $stdout = fopen('php://memory', 'w+'); + fwrite($stdout, implode(PHP_EOL, $mockExec)); + rewind($stdout); + $pipes = [fopen($mockProcStdinFile, 'w'), $stdout]; + return $stdout; +} + +function proc_close($process) +{ + global $mockExec; + return $mockExec ? 0 : \proc_close($process); +} + namespace Jeidison\JSignPDF\Tests; use org\bovigo\vfs\vfsStream; use Exception; +use Jeidison\JSignPDF\Sign\JSignParam; use Jeidison\JSignPDF\Sign\JSignService; use Jeidison\JSignPDF\Tests\Builder\JSignParamBuilder; use PHPUnit\Framework\Attributes\DataProvider; @@ -30,11 +52,36 @@ class JSignPDFTest extends TestCase protected function setUp(): void { - global $mockExec; + global $mockExec, $mockProcCommand, $mockProcStdinFile; $mockExec = null; + $mockProcCommand = null; + $mockProcStdinFile = null; $this->service = new JSignService(); } + protected function tearDown(): void + { + global $mockProcStdinFile; + if ($mockProcStdinFile && is_file($mockProcStdinFile)) { + unlink($mockProcStdinFile); + } + } + + private function withFakeRuntime(string $jSignPdfPath = 'vfs://download/jsignpdf'): JSignParam + { + $params = JSignParamBuilder::instance()->withDefault(); + vfsStream::setup('download'); + mkdir('vfs://download/jvava/bin', 0755, true); + touch('vfs://download/jvava/bin/java'); + chmod('vfs://download/jvava/bin/java', 0755); + $params->setJavaPath('vfs://download/jvava/bin/java'); + $params->setJavaDownloadUrl(''); + mkdir('vfs://download/jsignpdf', 0755, true); + $params->setjSignPdfJarPath($jSignPdfPath); + $params->setJSignPdfDownloadUrl(''); + return $params; + } + private function getNewCert($password, $expireDays = 365) { $privateKey = openssl_pkey_new([ @@ -228,4 +275,149 @@ public function testGetVersion() $version = $this->service->getVersion($params); $this->assertNotEmpty($version); } + + public function testGetVersionOfJSignPdf3(): void + { + global $mockExec; + $mockExec = ['JSignPdf version 3.1.0']; + + $params = JSignParamBuilder::instance()->withDefault(); + vfsStream::setup('download'); + mkdir('vfs://download/bin'); + touch('vfs://download/bin/java'); + chmod('vfs://download/bin/java', 0755); + mkdir('vfs://download/jsignpdf_fake_path/'); + touch('vfs://download/jsignpdf_fake_path/.jsignpdf_version_fake_url'); + $params->setJavaPath('vfs://download/bin/java'); + $params->setJSignPdfDownloadUrl('fake_url'); + $params->setIsUseJavaInstalled(true); + $params->setjSignPdfJarPath('vfs://download/jsignpdf_fake_path'); + $version = $this->service->getVersion($params); + $this->assertEquals('3.1.0', $version); + } + + public function testSignWhenJSignPdfReportsAFailure(): void + { + global $mockExec; + $mockExec = [ + 'INFO Creating signature', + 'INFO Finished: Creating of signature failed.', + ]; + $params = $this->withFakeRuntime(); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + + $this->expectExceptionMessageMatches('/Creating of signature failed/'); + $this->service->sign($params); + } + + public function testSignSendsThePasswordThroughStdinAndNotThroughArgv(): void + { + global $mockExec, $mockProcCommand, $mockProcStdinFile; + $mockExec = ['Finished: Signature succesfully created.']; + $password = 'with space $and `backtick` and ; semicolon'; + $params = $this->withFakeRuntime(); + $params->setCertificate($this->getNewCert($password)); + $params->setPassword($password); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertStringNotContainsString($password, $mockProcCommand); + $this->assertStringContainsString('--enable-stdin-passwords -ksp -', $mockProcCommand); + $this->assertEquals($password . PHP_EOL, file_get_contents($mockProcStdinFile)); + } + + public function testSignEscapesEveryPathOfTheCommand(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + mkdir("vfs://download/temp dir with 'quote'", 0755, true); + $params->setTempPath("vfs://download/temp dir with 'quote'/"); + $params->setCertificate($this->getNewCert($params->getPassword())); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertStringContainsString(escapeshellarg('vfs://download/jvava/bin/java'), $mockProcCommand); + $this->assertStringContainsString(escapeshellarg($params->getTempPdfPath()), $mockProcCommand); + $this->assertStringContainsString('-ksf ' . escapeshellarg($params->getTempCertificatePath()), $mockProcCommand); + $this->assertStringContainsString('-d ' . escapeshellarg($params->getPathPdfSigned()), $mockProcCommand); + } + + public function testSignUsesTheFatJarWhenTheDistributionShipsOne(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime('vfs://download/jsignpdf/JSignPdf.jar'); + touch('vfs://download/jsignpdf/JSignPdf.jar'); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertStringContainsString('-jar ' . escapeshellarg('vfs://download/jsignpdf/JSignPdf.jar'), $mockProcCommand); + } + + public function testSignEscapesOptionValuesGivenAsAList(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $options = [ + '-kst', + 'PKCS12', + '-ts', + 'https://tsa.example/tsr?first=1&second=2', + '-o', + "reason with space, ' quote and ; semicolon", + ]; + $params = $this->withFakeRuntime(); + $params->setJSignParameters($options); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertStringContainsString( + implode(' ', array_map('escapeshellarg', $options)), + $mockProcCommand + ); + } + + public function testSignKeepsOptionsGivenAsAStringUntouched(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setJSignParameters('-kst PKCS12 -ts https://freetsa.org/tsr'); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertStringContainsString('-kst PKCS12 -ts https://freetsa.org/tsr', $mockProcCommand); + } + + public function testSignUsesTheClasspathWhenTheDistributionHasNoFatJar(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime('vfs://download/jsignpdf/JSignPdf.jar'); + mkdir('vfs://download/jsignpdf/lib', 0755, true); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertStringContainsString( + '-classpath ' . escapeshellarg('vfs://download/jsignpdf/lib/*') . ' com.intoolswetrust.jsignpdf.Bootstrap', + $mockProcCommand + ); + } } diff --git a/tests/Runtime/JSignPdfRuntimeServiceTest.php b/tests/Runtime/JSignPdfRuntimeServiceTest.php new file mode 100644 index 0000000..f3da30f --- /dev/null +++ b/tests/Runtime/JSignPdfRuntimeServiceTest.php @@ -0,0 +1,192 @@ +testTmpDir = sys_get_temp_dir() . '/jsignpdf_zip_dir_' . uniqid(); + mkdir(directory: $this->testTmpDir, recursive: true); + } + + private function createZip(string $path, string $rootDir, array $files): void + { + $zip = new ZipArchive(); + $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addEmptyDir($rootDir); + foreach ($files as $name => $content) { + $zip->addFromString($rootDir . '/' . $name, $content); + } + $zip->close(); + } + + private function serve(string $zipPath, string $filename): string + { + $server = new MockWebServer(); + $server->start(); + $server->setResponseOfPath( + '/' . $filename, + new Response(file_get_contents($zipPath)), + ); + return $server->getServerRoot() . '/' . $filename; + } + + public function testGetPathWithCustomAndValidJarPath(): void + { + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + touch($this->testTmpDir . '/JSignPdf.jar'); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl(''); + $this->assertEquals($this->testTmpDir . '/JSignPdf.jar', $service->getPath($jsignParam)); + } + + public function testGetPathWithoutFatJarButWithLibDirectory(): void + { + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + mkdir($this->testTmpDir . '/lib'); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl(''); + $this->assertEquals($this->testTmpDir . '/JSignPdf.jar', $service->getPath($jsignParam)); + } + + public function testGetPathWhenNothingIsInstalled(): void + { + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl(''); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/JSignPDF not found/'); + $service->getPath($jsignParam); + } + + public function testGetPathWithoutJarPath(): void + { + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + $jsignParam->setjSignPdfJarPath(''); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Java not found/'); + $service->getPath($jsignParam); + } + + public function testDownloadAndExtractDistributionWithFatJar(): void + { + $zipPath = $this->testTmpDir . '/source.zip'; + $this->createZip($zipPath, 'jsignpdf-3.0.1', ['JSignPdf.jar' => 'fake jar content']); + $url = $this->serve($zipPath, 'jsignpdf-3.0.1.zip'); + unlink($zipPath); + + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl($url); + + $service->getPath($jsignParam); + + $this->assertFileExists($this->testTmpDir . '/install/JSignPdf.jar'); + $this->assertFileExists($this->testTmpDir . '/install/.jsignpdf_version_jsignpdf-3.0.1.zip'); + $this->assertFileDoesNotExist($this->testTmpDir . '/install/jsignpdf-3.0.1'); + } + + public function testDownloadAndExtractDistributionWithoutFatJar(): void + { + $zipPath = $this->testTmpDir . '/source.zip'; + $this->createZip($zipPath, 'jsignpdf-3.1.0', [ + 'lib/jsignpdf-engine-api-3.1.0.jar' => 'fake jar content', + 'bin/jsignpdf.sh' => 'fake launcher', + ]); + $url = $this->serve($zipPath, 'jsignpdf-3.1.0-minimal.zip'); + unlink($zipPath); + + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl($url); + + $service->getPath($jsignParam); + + $this->assertDirectoryExists($this->testTmpDir . '/install/lib'); + $this->assertFileExists($this->testTmpDir . '/install/lib/jsignpdf-engine-api-3.1.0.jar'); + $this->assertFileExists($this->testTmpDir . '/install/.jsignpdf_version_jsignpdf-3.1.0-minimal.zip'); + $this->assertFileDoesNotExist($this->testTmpDir . '/install/JSignPdf.jar'); + $this->assertFileDoesNotExist($this->testTmpDir . '/install/jsignpdf-3.1.0'); + } + + public function testDownloadIsSkippedWhenTheInstalledVersionMatches(): void + { + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + + mkdir($this->testTmpDir . '/lib'); + touch($this->testTmpDir . '/.jsignpdf_version_jsignpdf-3.1.0-minimal.zip'); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl('https://fake.url/jsignpdf-3.1.0-minimal.zip'); + + $this->assertEquals($this->testTmpDir . '/JSignPdf.jar', $service->getPath($jsignParam)); + } + + public function testDownloadWithInvalidUrl(): void + { + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl('invalid_url'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/url.*invalid/'); + $service->getPath($jsignParam); + } + + public function testDownloadWithInvalidZipFile(): void + { + $server = new MockWebServer(); + $server->start(); + $server->setResponseOfPath('/jsignpdf.zip', new Response('invalid body response')); + + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl($server->getServerRoot() . '/jsignpdf.zip'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/cannot be extracted/'); + $service->getPath($jsignParam); + } + + protected function tearDown(): void + { + $dirs = glob(sys_get_temp_dir() . '/jsignpdf_zip_dir_*', GLOB_ONLYDIR); + + foreach ($dirs as $dir) { + $this->removeDirectoryContents($dir); + rmdir($dir); + } + } + + private function removeDirectoryContents(string $dir): void + { + $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); + $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getRealPath()); + } else { + unlink($file->getRealPath()); + } + } + } +} From 819adca0896da8d25d0fcc0644c56e856b8b169f Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 1 Sep 2026 15:34:08 -0400 Subject: [PATCH 02/15] test: add integration tests running the real JSignPdf Signed-off-by: YvesCesar --- composer.json | 3 +- tests/Integration/SignPdfTest.php | 71 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/SignPdfTest.php diff --git a/composer.json b/composer.json index 62e668d..362a72e 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,8 @@ "scripts": { "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", - "test:unit": "vendor/bin/phpunit --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations", + "test:unit": "vendor/bin/phpunit --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations --exclude-group integration", + "test:integration": "vendor/bin/phpunit --no-coverage --colors=always --group integration", "test:coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit", "psalm": "psalm --no-cache --threads=$(nproc)", "psalm:update-baseline": "psalm --threads=$(nproc) --update-baseline --set-baseline=tests/psalm-baseline.xml", diff --git a/tests/Integration/SignPdfTest.php b/tests/Integration/SignPdfTest.php new file mode 100644 index 0000000..8c16835 --- /dev/null +++ b/tests/Integration/SignPdfTest.php @@ -0,0 +1,71 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + $csr = openssl_csr_new(['commonName' => 'Jhon Doe'], $privateKey, ['digest_alg' => 'sha256']); + $x509 = openssl_csr_sign($csr, null, $privateKey, 365); + openssl_pkcs12_export($x509, $certificate, $privateKey, self::PASSWORD); + + $params = JSignParam::instance(); + $params->setCertificate($certificate); + $params->setPdf(file_get_contents(__DIR__ . '/../resources/pdf-test.pdf')); + $params->setPassword(self::PASSWORD); + return $params; + } + + public function testGetVersionReturnsTheInstalledJSignPdf(): void + { + $version = JSignPDF::instance($this->params())->getVersion(); + $this->assertMatchesRegularExpression('/^3\.\d+\.\d+/', $version); + } + + public function testSignProducesASignedPdf(): void + { + $params = $this->params(); + $params->setJSignParameters(['-kst', 'PKCS12', '--overwrite']); + + $signed = JSignPDF::instance($params)->sign(); + + $this->assertStringStartsWith('%PDF-', $signed); + $this->assertStringContainsString('/ByteRange', $signed); + $this->assertStringContainsString('adbe.pkcs7', $signed); + } + + public function testSignWithAVisibleSignature(): void + { + $params = $this->params(); + $params->setJSignParameters([ + '-kst', 'PKCS12', + '--overwrite', + '-V', + '-pg', '1', + '-llx', '50', '-lly', '50', '-urx', '300', '-ury', '150', + ]); + + $signed = JSignPDF::instance($params)->sign(); + + $this->assertStringContainsString('/ByteRange', $signed); + } + + public function testSignAPdfOlderThan16WithTheDefaultParameters(): void + { + $this->expectExceptionMessageMatches('/Creating of signature failed/'); + JSignPDF::instance($this->params())->sign(); + } +} From 598d1b6022f30014758e0c9e968c86618c79ba40 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 1 Sep 2026 15:34:08 -0400 Subject: [PATCH 03/15] docs: document the JSignPdf 3.x behavior changes Signed-off-by: YvesCesar --- AGENTS.md | 12 ++++++++---- README.md | 29 ++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6bda044..2cf6bfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Whenever a change makes any guidance here outdated, update this file in the same ## What this is -A thin PHP wrapper around [JSignPdf](http://jsignpdf.sourceforge.net/) (a Java CLI tool) for digitally signing PDFs with a PKCS#12 certificate. The library shells out to `java -jar JSignPdf.jar` and can download both the JRE and the JSignPdf jar on demand. +A thin PHP wrapper around [JSignPdf](http://jsignpdf.sourceforge.net/) (a Java CLI tool) for digitally signing PDFs with a PKCS#12 certificate. The library shells out to java and can download both the JRE and JSignPdf on demand. Package name is `jsignpdf/jsignpdf-php`, but the PSR-4 namespace is `Jeidison\JSignPDF\` (`src/`) and `Jeidison\JSignPDF\Tests\` (`tests/`). @@ -19,6 +19,7 @@ Dev tooling lives in isolated `vendor-bin/*` directories managed by `bamarni/com ```bash composer install # deps + all vendor-bin tools composer run test:unit # PHPUnit (fails on warning/risky) +composer run test:integration # PHPUnit, integration group only composer run test:coverage # with xdebug coverage composer run cs:check # php-cs-fixer dry-run (CI lint) composer run cs:fix # apply formatting @@ -36,14 +37,17 @@ vendor/bin/phpunit tests/Runtime/JavaRuntimeServiceTest.php Minimum supported version is PHP 8.1 and `composer.json` pins `config.platform.php` to 8.1 — don't use syntax or stdlib newer than that. CI (on PRs only) runs php-cs-fixer, psalm, and PHPUnit on PHP 8.1–8.4. +JSignPdf 3.x is the supported target and needs a Java 21+ runtime. Two distribution layouts have to keep working: the fat jar shipped up to 3.0.x, and the `lib/` directory shipped since 3.1, which is started from the classpath instead. JSignPdf 2.x is not supported — it has no way to read passwords from stdin. + ## Constraints -Everything reaching a shell must go through `escapeshellarg()`. Secrets (certificate passwords in particular) must never be passed through argv — use stdin instead. +Everything reaching a shell must go through `escapeshellarg()`. Secrets (certificate passwords in particular) must never be passed through argv — use stdin instead. JSignPdf reads them from stdin when the option value is `-` and `--enable-stdin-passwords` is set. ## Testing patterns -- `tests/` mirrors the `src/` tree. Preserve the same relative path and append `Test` to the source class name. For example, `src/Runtime/JavaRuntimeService.php` is covered by `tests/Runtime/JavaRuntimeServiceTest.php`. `tests/Builder/` and `tests/resources/` are examples of support directories outside this mirror. -- Shell calls are covered by declaring an `exec()` function inside the tested namespace, shadowing the global one for that file, driven by a `$mockExec` global set per test (see `tests/JSignPDFTest.php`). +- `tests/` mirrors the `src/` tree. Preserve the same relative path and append `Test` to the source class name. For example, `src/Runtime/JavaRuntimeService.php` is covered by `tests/Runtime/JavaRuntimeServiceTest.php`. `tests/Builder/`, `tests/resources/` and `tests/Integration/` are examples of support directories outside this mirror. +- `tests/Integration/` holds the tests that run the real JSignPdf. They are tagged with `#[Group('integration')]`, excluded from `test:unit` and run by `test:integration`. They need network access and download the JRE and JSignPdf into `tmp/` on the first run. +- Shell calls are covered by declaring `exec()`, `proc_open()` and `proc_close()` functions inside the tested namespace, shadowing the global ones for that file, driven by a `$mockExec` global set per test (see `tests/JSignPDFTest.php`). The `proc_open()` shadow records the command in `$mockProcCommand` and writes the stdin it receives to `$mockProcStdinFile`, so tests can assert both what was passed as arguments and what was kept out of them. - `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar http download endpoints. - `tests/Builder/JSignParamBuilder::withDefault()` returns a `JSignParam` preloaded with `tests/resources/certificado.pfx` (password `123`) and `tests/resources/pdf-test.pdf`. - Psalm's baseline is `tests/psalm-baseline.xml` with `findUnusedBaselineEntry` on — removing an error means the baseline entry must go too and is updated by `composer psalm:update-baseline`. diff --git a/README.md b/README.md index 76338a9..8693c20 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,33 @@ Change parameters of JSignPDF: $param->setJSignParameters("-a -kst PKCS12 -ts https://freetsa.org/tsr"); ``` +A string is passed to JSignPdf as written, so it is the one place where the +content is not escaped for you. Never build one from untrusted input. To let +the package escape the values, pass a list of options and values instead: + +```php +$param->setJSignParameters(['-kst', 'PKCS12', '-ts', 'https://freetsa.org/tsr']); +``` + +## JSignPdf 3.x + +This package targets JSignPdf 3.x, which needs a Java 21+ runtime. Two changes +of JSignPdf 3.1 are worth knowing about: + +- the default hash algorithm is now SHA-256, which requires at least a PDF-1.6; +- the CLI appends the signature by default, and the append mode cannot upgrade + the PDF version. + +Together they make signing a PDF older than 1.6 fail with the default +parameters. To sign such a file, either turn off the append mode with +`--overwrite` or pick an algorithm the PDF version supports: + +```php +$param->setJSignParameters('-kst PKCS12 --overwrite'); +``` + +The `-a` flag is kept by JSignPdf 3.1 as a no-op. + ## Docker Environment The repository ships a minimal Docker setup (`Dockerfile` and `compose.yml`) with the extensions @@ -84,7 +111,7 @@ docker compose run --rm php composer run psalm ### Usage example -To sign a PDF end to end. It downloads the JRE and the JSignPdf jar into `tmp/` on the first run: +To sign a PDF end to end. It downloads the JRE and JSignPdf into `tmp/` on the first run: ```bash docker compose run --rm php php example/index.php From f3caf97038438a7a23ff57814497cd713bccea2e Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 1 Sep 2026 15:41:28 -0400 Subject: [PATCH 04/15] refactor: rename the ZipArchive variable in JSignPdfRuntimeService Signed-off-by: YvesCesar --- src/Runtime/JSignPdfRuntimeService.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Runtime/JSignPdfRuntimeService.php b/src/Runtime/JSignPdfRuntimeService.php index 894ea6d..be6c3ea 100644 --- a/src/Runtime/JSignPdfRuntimeService.php +++ b/src/Runtime/JSignPdfRuntimeService.php @@ -77,16 +77,16 @@ private function downloadAndExtract(JSignParam $params): void throw new InvalidArgumentException('The url to download Java is invalid: ' . $url); } $this->chunkDownload($url, $baseDir . '/jsignpdf.zip'); - $z = new ZipArchive(); - $ok = $z->open($baseDir . '/jsignpdf.zip'); + $zip = new ZipArchive(); + $ok = $zip->open($baseDir . '/jsignpdf.zip'); if ($ok !== true) { throw new InvalidArgumentException('The file ' . $baseDir . '/jsignpdf.zip cannot be extracted'); } - $rootDirInsideZip = $z->getNameIndex(0); + $rootDirInsideZip = $zip->getNameIndex(0); if (!is_string($rootDirInsideZip)) { throw new InvalidArgumentException('The file ' . $baseDir . '/jsignpdf.zip is empty'); } - $ok = $z->extractTo($baseDir); + $ok = $zip->extractTo($baseDir); if ($ok !== true) { throw new InvalidArgumentException('The file ' . $baseDir . '/jsignpdf.zip cannot be extracted'); } From 082e295d7feb6a2719435369612464e265bc4fb7 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 1 Sep 2026 17:47:36 -0400 Subject: [PATCH 05/15] fix: replace the previous JSignPdf install instead of merging into it Signed-off-by: YvesCesar --- src/Runtime/JSignPdfRuntimeService.php | 64 ++++++++++++++-- tests/Runtime/JSignPdfRuntimeServiceTest.php | 80 ++++++++++++++++++++ 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/Runtime/JSignPdfRuntimeService.php b/src/Runtime/JSignPdfRuntimeService.php index be6c3ea..4ba9f64 100644 --- a/src/Runtime/JSignPdfRuntimeService.php +++ b/src/Runtime/JSignPdfRuntimeService.php @@ -82,24 +82,72 @@ private function downloadAndExtract(JSignParam $params): void if ($ok !== true) { throw new InvalidArgumentException('The file ' . $baseDir . '/jsignpdf.zip cannot be extracted'); } - $rootDirInsideZip = $zip->getNameIndex(0); - if (!is_string($rootDirInsideZip)) { - throw new InvalidArgumentException('The file ' . $baseDir . '/jsignpdf.zip is empty'); - } - $ok = $zip->extractTo($baseDir); + $staging = $baseDir . '/.jsignpdf_staging_' . uniqid(); + $ok = $zip->extractTo($staging); + $zip->close(); if ($ok !== true) { + $this->deletePath($staging); throw new InvalidArgumentException('The file ' . $baseDir . '/jsignpdf.zip cannot be extracted'); } - @exec('mv ' . escapeshellarg($baseDir . '/'. $rootDirInsideZip) . '/* ' . escapeshellarg($baseDir)); - @exec('rm -rf ' . escapeshellarg($baseDir . '/'. $rootDirInsideZip)); - @exec('rm -f ' . escapeshellarg($baseDir) . '/.jsignpdf_version_*'); unlink($baseDir . '/jsignpdf.zip'); + try { + $this->replaceInstall($this->archiveRoot($staging), $baseDir); + } finally { + $this->deletePath($staging); + } + foreach (glob($baseDir . '/.jsignpdf_version_*') ?: [] as $previousVersion) { + unlink($previousVersion); + } if (!self::isInstalled($jsignPdfPath)) { throw new RuntimeException('JSignPdf not found at: ' . $baseDir); } touch($baseDir . '/.jsignpdf_version_' . basename($url)); } + private function archiveRoot(string $staging): string + { + $entries = array_values(array_diff(scandir($staging) ?: [], ['.', '..'])); + if (count($entries) === 1 && is_dir($staging . '/' . $entries[0])) { + return $staging . '/' . $entries[0]; + } + return $staging; + } + + private function replaceInstall(string $source, string $baseDir): void + { + $this->deletePath($baseDir . '/JSignPdf.jar'); + foreach (array_diff(scandir($source) ?: [], ['.', '..']) as $entry) { + $target = $baseDir . '/' . $entry; + $this->deletePath($target); + if (!rename($source . '/' . $entry, $target)) { + throw new RuntimeException('Failure to install JSignPdf at: ' . $target); + } + } + } + + private function deletePath(string $path): void + { + if (is_link($path) || is_file($path)) { + unlink($path); + return; + } + if (!is_dir($path)) { + return; + } + $contents = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($contents as $item) { + if ($item->isDir() && !$item->isLink()) { + rmdir($item->getPathname()); + continue; + } + unlink($item->getPathname()); + } + rmdir($path); + } + private function chunkDownload(string $url, string $destination): void { $fp = fopen($destination, 'w'); diff --git a/tests/Runtime/JSignPdfRuntimeServiceTest.php b/tests/Runtime/JSignPdfRuntimeServiceTest.php index f3da30f..4175248 100644 --- a/tests/Runtime/JSignPdfRuntimeServiceTest.php +++ b/tests/Runtime/JSignPdfRuntimeServiceTest.php @@ -126,6 +126,86 @@ public function testDownloadAndExtractDistributionWithoutFatJar(): void $this->assertFileDoesNotExist($this->testTmpDir . '/install/jsignpdf-3.1.0'); } + public function testUpgradeFromADistributionWithFatJarRemovesTheOldJar(): void + { + $zipPath = $this->testTmpDir . '/source.zip'; + $this->createZip($zipPath, 'jsignpdf-3.1.0', [ + 'lib/engine.jar' => 'new jar content', + 'bin/jsignpdf.sh' => 'launcher', + ]); + $url = $this->serve($zipPath, 'jsignpdf-3.1.0-minimal.zip'); + unlink($zipPath); + + mkdir($this->testTmpDir . '/install'); + file_put_contents($this->testTmpDir . '/install/JSignPdf.jar', 'old fat jar'); + touch($this->testTmpDir . '/install/.jsignpdf_version_jsignpdf-2.3.0.zip'); + + $jsignParam = new JSignParam(); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl($url); + (new JSignPdfRuntimeService())->getPath($jsignParam); + + $this->assertFileDoesNotExist($this->testTmpDir . '/install/JSignPdf.jar'); + $this->assertFileDoesNotExist($this->testTmpDir . '/install/.jsignpdf_version_jsignpdf-2.3.0.zip'); + $this->assertFileExists($this->testTmpDir . '/install/lib/engine.jar'); + $this->assertFileExists($this->testTmpDir . '/install/.jsignpdf_version_jsignpdf-3.1.0-minimal.zip'); + } + + public function testUpgradeBetweenDistributionsWithoutFatJarReplacesTheLibDirectory(): void + { + $zipPath = $this->testTmpDir . '/source.zip'; + $this->createZip($zipPath, 'jsignpdf-3.2.0', ['lib/engine-3.2.0.jar' => 'new jar content']); + $url = $this->serve($zipPath, 'jsignpdf-3.2.0-minimal.zip'); + unlink($zipPath); + + mkdir($this->testTmpDir . '/install/lib', 0755, true); + file_put_contents($this->testTmpDir . '/install/lib/engine-3.1.0.jar', 'old jar'); + touch($this->testTmpDir . '/install/.jsignpdf_version_jsignpdf-3.1.0-minimal.zip'); + + $jsignParam = new JSignParam(); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl($url); + (new JSignPdfRuntimeService())->getPath($jsignParam); + + $this->assertFileExists($this->testTmpDir . '/install/lib/engine-3.2.0.jar'); + $this->assertFileDoesNotExist($this->testTmpDir . '/install/lib/engine-3.1.0.jar'); + $this->assertFileExists($this->testTmpDir . '/install/.jsignpdf_version_jsignpdf-3.2.0-minimal.zip'); + } + + public function testDownloadAndExtractArchiveWithoutRootDirectoryEntry(): void + { + $zipPath = $this->testTmpDir . '/source.zip'; + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFromString('jsignpdf-3.1.0/lib/engine.jar', 'jar content'); + $zip->close(); + $url = $this->serve($zipPath, 'jsignpdf-3.1.0-minimal.zip'); + unlink($zipPath); + + $jsignParam = new JSignParam(); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl($url); + (new JSignPdfRuntimeService())->getPath($jsignParam); + + $this->assertFileExists($this->testTmpDir . '/install/lib/engine.jar'); + } + + public function testStagingDirectoryIsRemovedAfterTheInstall(): void + { + $zipPath = $this->testTmpDir . '/source.zip'; + $this->createZip($zipPath, 'jsignpdf-3.1.0', ['lib/engine.jar' => 'jar content']); + $url = $this->serve($zipPath, 'jsignpdf-3.1.0-minimal.zip'); + unlink($zipPath); + + $jsignParam = new JSignParam(); + $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfDownloadUrl($url); + (new JSignPdfRuntimeService())->getPath($jsignParam); + + $this->assertEmpty(glob($this->testTmpDir . '/install/.jsignpdf_staging_*')); + $this->assertFileDoesNotExist($this->testTmpDir . '/install/jsignpdf.zip'); + } + public function testDownloadIsSkippedWhenTheInstalledVersionMatches(): void { $jsignParam = new JSignParam(); From 00c62d12829c207289358a8673dab2be541861a8 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 1 Sep 2026 17:47:36 -0400 Subject: [PATCH 06/15] fix: prefer the classpath layout over a leftover fat jar Signed-off-by: YvesCesar --- src/Sign/JSignService.php | 9 +++++++-- tests/JSignPDFTest.php | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Sign/JSignService.php b/src/Sign/JSignService.php index 39aa811..34ccb19 100644 --- a/src/Sign/JSignService.php +++ b/src/Sign/JSignService.php @@ -147,7 +147,7 @@ private function jSignPdfInvocation(JSignParam $params): string { $jSignPdfPath = $this->getjSignPdfJarPath($params); $libDir = JSignPdfRuntimeService::baseDir($jSignPdfPath) . '/lib'; - if (!is_file($jSignPdfPath) && is_dir($libDir)) { + if (is_dir($libDir)) { return '-classpath ' . escapeshellarg($libDir . '/*') . ' ' . self::MAIN_CLASS; } return '-jar ' . escapeshellarg($jSignPdfPath); @@ -164,8 +164,13 @@ private function execWithPasswordOnStdin(string $command, string $password): arr if (!is_resource($process)) { throw new Exception('Error to sign PDF.'); } - fwrite($pipes[0], $password . PHP_EOL); + $written = fwrite($pipes[0], $password . PHP_EOL); fclose($pipes[0]); + if ($written === false) { + fclose($pipes[1]); + proc_close($process); + throw new Exception('Error to sign PDF.'); + } $output = stream_get_contents($pipes[1]); fclose($pipes[1]); proc_close($process); diff --git a/tests/JSignPDFTest.php b/tests/JSignPDFTest.php index d3f174f..4d8e7db 100644 --- a/tests/JSignPDFTest.php +++ b/tests/JSignPDFTest.php @@ -362,6 +362,26 @@ public function testSignUsesTheFatJarWhenTheDistributionShipsOne(): void $this->assertStringContainsString('-jar ' . escapeshellarg('vfs://download/jsignpdf/JSignPdf.jar'), $mockProcCommand); } + public function testSignPrefersTheClasspathOverALeftoverFatJar(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime('vfs://download/jsignpdf/JSignPdf.jar'); + touch('vfs://download/jsignpdf/JSignPdf.jar'); + mkdir('vfs://download/jsignpdf/lib', 0755, true); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertStringContainsString( + '-classpath ' . escapeshellarg('vfs://download/jsignpdf/lib/*'), + $mockProcCommand + ); + $this->assertStringNotContainsString('-jar ', $mockProcCommand); + } + public function testSignEscapesOptionValuesGivenAsAList(): void { global $mockExec, $mockProcCommand; From 3ff53d9eabfae2bc436b922423155c4060f3356c Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 1 Sep 2026 17:47:36 -0400 Subject: [PATCH 07/15] test: cover the hash algorithm option and keep coverage off the integration group Signed-off-by: YvesCesar --- composer.json | 2 +- tests/Integration/SignPdfTest.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 362a72e..816b439 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "cs:fix": "php-cs-fixer fix", "test:unit": "vendor/bin/phpunit --no-coverage --colors=always --fail-on-warning --fail-on-risky --display-deprecations --display-phpunit-deprecations --exclude-group integration", "test:integration": "vendor/bin/phpunit --no-coverage --colors=always --group integration", - "test:coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit", + "test:coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --exclude-group integration", "psalm": "psalm --no-cache --threads=$(nproc)", "psalm:update-baseline": "psalm --threads=$(nproc) --update-baseline --set-baseline=tests/psalm-baseline.xml", "post-install-cmd": [ diff --git a/tests/Integration/SignPdfTest.php b/tests/Integration/SignPdfTest.php index 8c16835..99a4cf6 100644 --- a/tests/Integration/SignPdfTest.php +++ b/tests/Integration/SignPdfTest.php @@ -63,6 +63,16 @@ public function testSignWithAVisibleSignature(): void $this->assertStringContainsString('/ByteRange', $signed); } + public function testSignWithAnExplicitHashAlgorithm(): void + { + $params = $this->params(); + $params->setJSignParameters(['-kst', 'PKCS12', '--overwrite', '-ha', 'SHA512']); + + $signed = JSignPDF::instance($params)->sign(); + + $this->assertStringContainsString('/ByteRange', $signed); + } + public function testSignAPdfOlderThan16WithTheDefaultParameters(): void { $this->expectExceptionMessageMatches('/Creating of signature failed/'); From d70261b93f8d535ce15129e69d9076e921fe320a Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 1 Sep 2026 17:47:36 -0400 Subject: [PATCH 08/15] ci: run the integration tests in their own job Signed-off-by: YvesCesar --- .github/workflows/phpunit.yml | 31 +++++++++++++++++++++++++++++-- AGENTS.md | 2 +- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 04dde9d..45deb3b 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -64,11 +64,38 @@ jobs: - name: PHPUnit run: composer run test:unit + integration: + runs-on: ubuntu-latest + + name: Integration + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up php + uses: shivammathur/setup-php@7bf05c6b704e0b9bfee22300130a31b5ea68d593 # v2.36.0 + with: + php-version: 8.4 + extensions: json, openssl, zip + coverage: none + ini-file: development + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up dependencies + run: composer i + + - name: PHPUnit + run: composer run test:integration + summary: permissions: contents: none runs-on: ubuntu-latest - needs: [changes, phpunit] + needs: [changes, phpunit, integration] if: always() @@ -76,4 +103,4 @@ jobs: steps: - name: Summary status - run: if ${{ needs.changes.outputs.src != 'false' && needs.phpunit.result != 'success' }}; then exit 1; fi + run: if ${{ needs.changes.outputs.src != 'false' && (needs.phpunit.result != 'success' || needs.integration.result != 'success') }}; then exit 1; fi diff --git a/AGENTS.md b/AGENTS.md index 2cf6bfc..2028162 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Everything reaching a shell must go through `escapeshellarg()`. Secrets (certifi ## Testing patterns - `tests/` mirrors the `src/` tree. Preserve the same relative path and append `Test` to the source class name. For example, `src/Runtime/JavaRuntimeService.php` is covered by `tests/Runtime/JavaRuntimeServiceTest.php`. `tests/Builder/`, `tests/resources/` and `tests/Integration/` are examples of support directories outside this mirror. -- `tests/Integration/` holds the tests that run the real JSignPdf. They are tagged with `#[Group('integration')]`, excluded from `test:unit` and run by `test:integration`. They need network access and download the JRE and JSignPdf into `tmp/` on the first run. +- `tests/Integration/` holds the tests that run the real JSignPdf. They are tagged with `#[Group('integration')]`, excluded from `test:unit` and run by `test:integration`. They need network access and download the JRE and JSignPdf into `tmp/` on the first run. CI runs them in their own job, separate from the PHP version matrix. - Shell calls are covered by declaring `exec()`, `proc_open()` and `proc_close()` functions inside the tested namespace, shadowing the global ones for that file, driven by a `$mockExec` global set per test (see `tests/JSignPDFTest.php`). The `proc_open()` shadow records the command in `$mockProcCommand` and writes the stdin it receives to `$mockProcStdinFile`, so tests can assert both what was passed as arguments and what was kept out of them. - `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar http download endpoints. - `tests/Builder/JSignParamBuilder::withDefault()` returns a `JSignParam` preloaded with `tests/resources/certificado.pfx` (password `123`) and `tests/resources/pdf-test.pdf`. From 3d4413a8b55cbc02fc12a251bf1f2fe800af7471 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 1 Sep 2026 17:47:36 -0400 Subject: [PATCH 09/15] docs: state that JSignPdf 2.x is no longer supported Signed-off-by: YvesCesar --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8693c20..0ffedbd 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,13 @@ $param->setJSignParameters(['-kst', 'PKCS12', '-ts', 'https://freetsa.org/tsr']) ## JSignPdf 3.x -This package targets JSignPdf 3.x, which needs a Java 21+ runtime. Two changes -of JSignPdf 3.1 are worth knowing about: +This package targets JSignPdf 3.x, which needs a Java 21+ runtime. JSignPdf 2.x +is no longer supported: the certificate password is now sent to JSignPdf +through stdin, and 2.x has no option to read it from there. Pointing +`setJSignPdfDownloadUrl()` or `setjSignPdfJarPath()` at a 2.x release stops +working. + +Two changes of JSignPdf 3.1 are worth knowing about: - the default hash algorithm is now SHA-256, which requires at least a PDF-1.6; - the CLI appends the signature by default, and the append mode cannot upgrade From 5410fb696ef7c5730220d8e1ba3101b44c700829 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Wed, 2 Sep 2026 10:01:40 -0400 Subject: [PATCH 10/15] feat: keep every JSignPdf password out of argv Signed-off-by: YvesCesar --- AGENTS.md | 2 +- README.md | 26 ++++++ src/Sign/JSignParam.php | 93 +++++++++++++++++++- src/Sign/JSignService.php | 14 ++- tests/Integration/SignPdfTest.php | 11 +++ tests/JSignPDFTest.php | 141 ++++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2028162..6db2137 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ JSignPdf 3.x is the supported target and needs a Java 21+ runtime. Two distribut ## Constraints -Everything reaching a shell must go through `escapeshellarg()`. Secrets (certificate passwords in particular) must never be passed through argv — use stdin instead. JSignPdf reads them from stdin when the option value is `-` and `--enable-stdin-passwords` is set. +Everything reaching a shell must go through `escapeshellarg()`. Secrets must never be passed through argv — use stdin instead. JSignPdf reads a password from stdin when the option value is `-` and `--enable-stdin-passwords` is set. Every password option goes through it, not only the keystore one: values are read one line each, in the fixed order `-ksp`, `-kp`, `-opwd`, `-upwd`, `-tscp`, `-tsp`, so `JSignParam::getPasswords()` keeps that order and `JSignService` writes the lines in it. The only secret that can still reach argv is one written into the string form of `setJSignParameters()`, which is passed through unparsed. ## Testing patterns diff --git a/README.md b/README.md index 0ffedbd..83a8f7d 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,32 @@ the package escape the values, pass a list of options and values instead: $param->setJSignParameters(['-kst', 'PKCS12', '-ts', 'https://freetsa.org/tsr']); ``` +## Passwords + +Besides the certificate password of `setPassword()`, JSignPdf takes a password +for the private key, for encrypted documents and for the timestamping server. +None of them is passed on the command line, where any user of the machine could +read it from `ps` or `/proc//cmdline`: the package sends every one of them +to JSignPdf through stdin. + +```php +$param->setKeyPassword('private key password'); // -kp +$param->setOwnerPassword('owner password'); // -opwd +$param->setUserPassword('user password'); // -upwd +$param->setTsaCertPassword('tsa cert password'); // -tscp +$param->setTsaPassword('tsa password'); // -tsp +``` + +Passing one of those options to `setJSignParameters()` as a list works too, and +the value is taken out of the command line just the same: + +```php +$param->setJSignParameters(['-ts', 'https://freetsa.org/tsr', '-ta', 'PASSWORD', '-tsu', 'jhon', '-tsp', 'tsa password']); +``` + +The string form is the exception, as nothing in it is parsed: a password +written there does reach the command line. Use a setter or a list for it. + ## JSignPdf 3.x This package targets JSignPdf 3.x, which needs a Java 21+ runtime. JSignPdf 2.x diff --git a/src/Sign/JSignParam.php b/src/Sign/JSignParam.php index 95e4164..cae78ee 100644 --- a/src/Sign/JSignParam.php +++ b/src/Sign/JSignParam.php @@ -7,6 +7,15 @@ */ class JSignParam { + /** @var array */ + private const PASSWORD_OPTIONS = [ + '-kp' => '--key-password', + '-opwd' => '--owner-password', + '-upwd' => '--user-password', + '-tscp' => '--tsa-cert-password', + '-tsp' => '--tsa-password', + ]; + private string $pdf = ''; private string $certificate = ''; private string $password = ''; @@ -20,6 +29,10 @@ class JSignParam private string $jSignPdfJarPath = ''; private string $javaDownloadUrl = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.8_9.tar.gz'; private string $jSignPdfDownloadUrl = 'https://github.com/intoolswetrust/jsignpdf/releases/download/JSignPdf_3_1_0/jsignpdf-3.1.0-minimal.zip'; + /** @var array */ + private array $passwords = []; + /** @var array */ + private array $parameterPasswords = []; public function __construct() { @@ -88,13 +101,91 @@ public function getJSignParameters(): string */ public function setJSignParameters(string|array $JSignParameters): self { + $this->parameterPasswords = []; if (is_array($JSignParameters)) { - $JSignParameters = implode(' ', array_map('escapeshellarg', $JSignParameters)); + $JSignParameters = implode(' ', array_map('escapeshellarg', $this->takePasswords($JSignParameters))); } $this->JSignParameters = $JSignParameters; return $this; } + public function setKeyPassword(string $password): self + { + return $this->setPasswordOption('-kp', $password); + } + + public function setOwnerPassword(string $password): self + { + return $this->setPasswordOption('-opwd', $password); + } + + public function setUserPassword(string $password): self + { + return $this->setPasswordOption('-upwd', $password); + } + + public function setTsaCertPassword(string $password): self + { + return $this->setPasswordOption('-tscp', $password); + } + + public function setTsaPassword(string $password): self + { + return $this->setPasswordOption('-tsp', $password); + } + + /** @return array */ + public function getPasswords(): array + { + $passwords = []; + foreach (array_keys(self::PASSWORD_OPTIONS) as $option) { + $password = $this->passwords[$option] ?? $this->parameterPasswords[$option] ?? null; + if ($password !== null) { + $passwords[$option] = $password; + } + } + return $passwords; + } + + private function setPasswordOption(string $option, string $password): self + { + $this->passwords[$option] = $password; + return $this; + } + + /** + * @param list $parameters + * @return list + */ + private function takePasswords(array $parameters): array + { + $remaining = []; + for ($i = 0; $i < count($parameters); $i++) { + $option = $this->passwordOption($parameters[$i]); + if ($option !== null && isset($parameters[$i + 1]) && $parameters[$i + 1] !== '-') { + $this->parameterPasswords[$option] = $parameters[++$i]; + continue; + } + $assignment = explode('=', $parameters[$i], 2); + $option = count($assignment) === 2 ? $this->passwordOption($assignment[0]) : null; + if ($option !== null && $assignment[1] !== '-') { + $this->parameterPasswords[$option] = $assignment[1]; + continue; + } + $remaining[] = $parameters[$i]; + } + return $remaining; + } + + private function passwordOption(string $parameter): ?string + { + if (isset(self::PASSWORD_OPTIONS[$parameter])) { + return $parameter; + } + $option = array_search($parameter, self::PASSWORD_OPTIONS, true); + return $option === false ? null : $option; + } + public function getTempPath(): string { return $this->tempPath; diff --git a/src/Sign/JSignService.php b/src/Sign/JSignService.php index 34ccb19..a867f5b 100644 --- a/src/Sign/JSignService.php +++ b/src/Sign/JSignService.php @@ -29,7 +29,7 @@ public function sign(JSignParam $params): string $this->validation($params); $commandSign = $this->commandSign($params); - $output = $this->execWithPasswordOnStdin($commandSign, $params->getPassword()); + $output = $this->execWithPasswordsOnStdin($commandSign, $params); $out = json_encode($output); if ($out === false) { @@ -140,7 +140,12 @@ private function commandSign(JSignParam $params): string $certificate = escapeshellarg($certificate); $pathPdfSigned = escapeshellarg($params->getPathPdfSigned()); - return "$java -Duser.language=en $jSignPdf $pdf -ksf $certificate --enable-stdin-passwords -ksp - {$params->getJSignParameters()} -d $pathPdfSigned 2>&1"; + $passwords = ''; + foreach (array_keys($params->getPasswords()) as $option) { + $passwords .= "$option - "; + } + + return "$java -Duser.language=en $jSignPdf $pdf -ksf $certificate --enable-stdin-passwords -ksp - {$passwords}{$params->getJSignParameters()} -d $pathPdfSigned 2>&1"; } private function jSignPdfInvocation(JSignParam $params): string @@ -153,8 +158,9 @@ private function jSignPdfInvocation(JSignParam $params): string return '-jar ' . escapeshellarg($jSignPdfPath); } - private function execWithPasswordOnStdin(string $command, string $password): array + private function execWithPasswordsOnStdin(string $command, JSignParam $params): array { + $passwords = array_merge([$params->getPassword()], array_values($params->getPasswords())); $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], @@ -164,7 +170,7 @@ private function execWithPasswordOnStdin(string $command, string $password): arr if (!is_resource($process)) { throw new Exception('Error to sign PDF.'); } - $written = fwrite($pipes[0], $password . PHP_EOL); + $written = fwrite($pipes[0], implode(PHP_EOL, $passwords) . PHP_EOL); fclose($pipes[0]); if ($written === false) { fclose($pipes[1]); diff --git a/tests/Integration/SignPdfTest.php b/tests/Integration/SignPdfTest.php index 99a4cf6..ba0d839 100644 --- a/tests/Integration/SignPdfTest.php +++ b/tests/Integration/SignPdfTest.php @@ -47,6 +47,17 @@ public function testSignProducesASignedPdf(): void $this->assertStringContainsString('adbe.pkcs7', $signed); } + public function testSignSendingMoreThanOnePasswordThroughStdin(): void + { + $params = $this->params(); + $params->setJSignParameters(['-kst', 'PKCS12', '--overwrite']); + $params->setKeyPassword(self::PASSWORD); + + $signed = JSignPDF::instance($params)->sign(); + + $this->assertStringContainsString('/ByteRange', $signed); + } + public function testSignWithAVisibleSignature(): void { $params = $this->params(); diff --git a/tests/JSignPDFTest.php b/tests/JSignPDFTest.php index 4d8e7db..13d2a8b 100644 --- a/tests/JSignPDFTest.php +++ b/tests/JSignPDFTest.php @@ -440,4 +440,145 @@ public function testSignUsesTheClasspathWhenTheDistributionHasNoFatJar(): void $mockProcCommand ); } + + private function signWithFakeRuntime(JSignParam $params): void + { + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + } + + public function testSignSendsEveryPasswordThroughStdinInTheOrderJSignPdfReadsThem(): void + { + global $mockExec, $mockProcCommand, $mockProcStdinFile; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setTsaPassword('tsa secret'); + $params->setKeyPassword('key secret'); + $params->setUserPassword('user secret'); + $params->setTsaCertPassword('tsa cert secret'); + $params->setOwnerPassword('owner secret'); + + $this->signWithFakeRuntime($params); + + $this->assertStringContainsString( + '--enable-stdin-passwords -ksp - -kp - -opwd - -upwd - -tscp - -tsp - ', + $mockProcCommand + ); + $this->assertEquals( + implode(PHP_EOL, [ + $params->getPassword(), + 'key secret', + 'owner secret', + 'user secret', + 'tsa cert secret', + 'tsa secret', + ]) . PHP_EOL, + file_get_contents($mockProcStdinFile) + ); + foreach (['tsa secret', 'key secret', 'user secret', 'tsa cert secret', 'owner secret'] as $password) { + $this->assertStringNotContainsString($password, $mockProcCommand); + } + } + + public function testSignKeepsThePasswordsOfTheOptionListOutOfArgv(): void + { + global $mockExec, $mockProcCommand, $mockProcStdinFile; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setJSignParameters([ + '-kst', 'PKCS12', + '--overwrite', + '-ts', 'https://tsa.example/tsr', + '-ta', 'PASSWORD', + '-tsu', 'jhon', + '-tsp', 'tsa secret', + ]); + + $this->signWithFakeRuntime($params); + + $this->assertStringContainsString('--enable-stdin-passwords -ksp - -tsp - ', $mockProcCommand); + $this->assertStringNotContainsString('tsa secret', $mockProcCommand); + $this->assertStringContainsString( + implode(' ', array_map('escapeshellarg', ['-kst', 'PKCS12', '--overwrite', '-ts', 'https://tsa.example/tsr', '-ta', 'PASSWORD', '-tsu', 'jhon'])), + $mockProcCommand + ); + $this->assertEquals( + $params->getPassword() . PHP_EOL . 'tsa secret' . PHP_EOL, + file_get_contents($mockProcStdinFile) + ); + } + + #[DataProvider('providerPasswordOptionSpellings')] + public function testSignKeepsThePasswordOutOfArgvForEverySpellingOfTheOption(array $parameters): void + { + global $mockExec, $mockProcCommand, $mockProcStdinFile; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setJSignParameters($parameters); + + $this->signWithFakeRuntime($params); + + $this->assertStringContainsString('--enable-stdin-passwords -ksp - -tsp - ', $mockProcCommand); + $this->assertStringNotContainsString('tsa secret', $mockProcCommand); + $this->assertEquals( + $params->getPassword() . PHP_EOL . 'tsa secret' . PHP_EOL, + file_get_contents($mockProcStdinFile) + ); + } + + public static function providerPasswordOptionSpellings(): array + { + return [ + 'short option' => [['-tsp', 'tsa secret']], + 'long option' => [['--tsa-password', 'tsa secret']], + 'short option with assignment' => [['-tsp=tsa secret']], + 'long option with assignment' => [['--tsa-password=tsa secret']], + ]; + } + + public function testSignPrefersThePasswordOfTheSetterOverTheOneOfTheOptionList(): void + { + global $mockExec, $mockProcStdinFile; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setJSignParameters(['-tsp', 'from the list']); + $params->setTsaPassword('from the setter'); + + $this->signWithFakeRuntime($params); + + $this->assertEquals( + $params->getPassword() . PHP_EOL . 'from the setter' . PHP_EOL, + file_get_contents($mockProcStdinFile) + ); + } + + public function testSignForgetsThePasswordsOfAReplacedOptionList(): void + { + global $mockExec, $mockProcCommand, $mockProcStdinFile; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setJSignParameters(['-tsp', 'tsa secret']); + $params->setJSignParameters(['-kst', 'PKCS12']); + + $this->signWithFakeRuntime($params); + + $this->assertStringNotContainsString('-tsp', $mockProcCommand); + $this->assertEquals($params->getPassword() . PHP_EOL, file_get_contents($mockProcStdinFile)); + } + + public function testSignKeepsAPasswordAlreadyMarkedAsReadFromStdin(): void + { + global $mockExec, $mockProcCommand, $mockProcStdinFile; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setJSignParameters(['-tsp', '-']); + + $this->signWithFakeRuntime($params); + + $this->assertStringContainsString(escapeshellarg('-tsp') . ' ' . escapeshellarg('-'), $mockProcCommand); + $this->assertEquals($params->getPassword() . PHP_EOL, file_get_contents($mockProcStdinFile)); + } } From e302ad1116b19126ee91dc45958a630674a0da64 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Thu, 3 Sep 2026 10:04:56 -0400 Subject: [PATCH 11/15] refactor: derive the default JSignPdf download URL from a version constant Signed-off-by: YvesCesar --- src/Sign/JSignParam.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Sign/JSignParam.php b/src/Sign/JSignParam.php index cae78ee..cabcac2 100644 --- a/src/Sign/JSignParam.php +++ b/src/Sign/JSignParam.php @@ -16,6 +16,8 @@ class JSignParam '-tsp' => '--tsa-password', ]; + private const JSIGNPDF_VERSION = '3.1.0'; + private string $pdf = ''; private string $certificate = ''; private string $password = ''; @@ -28,7 +30,7 @@ class JSignParam private bool $isOutputTypeBase64 = false; private string $jSignPdfJarPath = ''; private string $javaDownloadUrl = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.8_9.tar.gz'; - private string $jSignPdfDownloadUrl = 'https://github.com/intoolswetrust/jsignpdf/releases/download/JSignPdf_3_1_0/jsignpdf-3.1.0-minimal.zip'; + private string $jSignPdfDownloadUrl = ''; /** @var array */ private array $passwords = []; /** @var array */ @@ -40,6 +42,13 @@ public function __construct() $this->tempPath = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR; $this->javaPath = $this->tempPath . 'java' . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'java'; $this->jSignPdfJarPath = $this->tempPath . 'jsignpdf' . DIRECTORY_SEPARATOR . 'JSignPdf.jar'; + $this->jSignPdfDownloadUrl = self::buildJSignPdfDownloadUrl(); + } + + private static function buildJSignPdfDownloadUrl(): string + { + $tag = 'JSignPdf_' . str_replace('.', '_', self::JSIGNPDF_VERSION); + return "https://github.com/intoolswetrust/jsignpdf/releases/download/$tag/jsignpdf-" . self::JSIGNPDF_VERSION . '-minimal.zip'; } public static function instance(): self From b0f76a3c8c30a8a4bae9ecfd18cc09fa31cdec10 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Thu, 3 Sep 2026 10:25:24 -0400 Subject: [PATCH 12/15] feat: accept an installation directory instead of a fake JSignPdf.jar path Signed-off-by: YvesCesar --- README.md | 4 +-- src/Runtime/JSignPdfRuntimeService.php | 29 ++++++----------- src/Sign/JSignParam.php | 12 +++---- src/Sign/JSignService.php | 8 ++--- tests/JSignPDFTest.php | 34 ++++++++++++-------- tests/Runtime/JSignPdfRuntimeServiceTest.php | 32 +++++++++--------- 6 files changed, 57 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 83a8f7d..d3f0b94 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ $param->setJavaPath('/path/to/bin/java'); With JSignPDF bin: ```php -$param->setjSignPdfJarPath('/path/to/jsignpdf'); +$param->setJSignPdfPath('/path/to/jsignpdf'); ``` With specific Java or JSignPdf version: ```php @@ -103,7 +103,7 @@ written there does reach the command line. Use a setter or a list for it. This package targets JSignPdf 3.x, which needs a Java 21+ runtime. JSignPdf 2.x is no longer supported: the certificate password is now sent to JSignPdf through stdin, and 2.x has no option to read it from there. Pointing -`setJSignPdfDownloadUrl()` or `setjSignPdfJarPath()` at a 2.x release stops +`setJSignPdfDownloadUrl()` or `setJSignPdfPath()` at a 2.x release stops working. Two changes of JSignPdf 3.1 are worth knowing about: diff --git a/src/Runtime/JSignPdfRuntimeService.php b/src/Runtime/JSignPdfRuntimeService.php index 4ba9f64..75888a8 100644 --- a/src/Runtime/JSignPdfRuntimeService.php +++ b/src/Runtime/JSignPdfRuntimeService.php @@ -13,22 +13,21 @@ class JSignPdfRuntimeService { public function getPath(JSignParam $params): string { - $jsignPdfPath = $params->getjSignPdfJarPath(); + $jsignPdfPath = $params->getJSignPdfPath(); $downloadUrl = $params->getJSignPdfDownloadUrl(); if ($jsignPdfPath && !$downloadUrl) { if (self::isInstalled($jsignPdfPath)) { return $jsignPdfPath; } - throw new InvalidArgumentException('Jar of JSignPDF not found on path: '. $jsignPdfPath); + throw new InvalidArgumentException('JSignPDF not found on path: '. $jsignPdfPath); } if ($downloadUrl && $jsignPdfPath) { - $baseDir = self::baseDir($jsignPdfPath); - if (!is_dir($baseDir)) { - $ok = mkdir($baseDir, 0755, true); + if (!is_dir($jsignPdfPath)) { + $ok = mkdir($jsignPdfPath, 0755, true); if ($ok === false) { - throw new InvalidArgumentException('The JSignPdf base dir cannot be created: '. $baseDir); + throw new InvalidArgumentException('The JSignPdf base dir cannot be created: '. $jsignPdfPath); } } if (!self::isInstalled($jsignPdfPath) || !self::validateVersion($params)) { @@ -40,33 +39,23 @@ public function getPath(JSignParam $params): string throw new InvalidArgumentException('Java not found.'); } - public static function baseDir(string $jsignPdfPath): string - { - $baseDir = preg_replace('/\/JSignPdf.jar$/', '', $jsignPdfPath); - if (!is_string($baseDir)) { - throw new InvalidArgumentException('Invalid JsignParamPath'); - } - return $baseDir; - } - private static function isInstalled(string $jsignPdfPath): bool { - return file_exists($jsignPdfPath) || is_dir(self::baseDir($jsignPdfPath) . '/lib'); + return is_dir($jsignPdfPath . '/lib') || file_exists($jsignPdfPath . '/JSignPdf.jar'); } private function validateVersion(JSignParam $params): bool { - $baseDir = self::baseDir($params->getjSignPdfJarPath()); + $baseDir = $params->getJSignPdfPath(); $versionCacheFile = $baseDir . '/.jsignpdf_version_' . basename($params->getJSignPdfDownloadUrl()); return file_exists($versionCacheFile); } private function downloadAndExtract(JSignParam $params): void { - $jsignPdfPath = $params->getjSignPdfJarPath(); + $baseDir = $params->getJSignPdfPath(); $url = $params->getJSignPdfDownloadUrl(); - $baseDir = self::baseDir($jsignPdfPath); if (!is_dir($baseDir)) { $ok = mkdir($baseDir, 0755, true); if (!$ok) { @@ -98,7 +87,7 @@ private function downloadAndExtract(JSignParam $params): void foreach (glob($baseDir . '/.jsignpdf_version_*') ?: [] as $previousVersion) { unlink($previousVersion); } - if (!self::isInstalled($jsignPdfPath)) { + if (!self::isInstalled($baseDir)) { throw new RuntimeException('JSignPdf not found at: ' . $baseDir); } touch($baseDir . '/.jsignpdf_version_' . basename($url)); diff --git a/src/Sign/JSignParam.php b/src/Sign/JSignParam.php index cabcac2..2c2302e 100644 --- a/src/Sign/JSignParam.php +++ b/src/Sign/JSignParam.php @@ -28,7 +28,7 @@ class JSignParam private string $tempPath = ''; private string $tempName = ''; private bool $isOutputTypeBase64 = false; - private string $jSignPdfJarPath = ''; + private string $jSignPdfPath = ''; private string $javaDownloadUrl = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/OpenJDK21U-jre_x64_linux_hotspot_21.0.8_9.tar.gz'; private string $jSignPdfDownloadUrl = ''; /** @var array */ @@ -41,7 +41,7 @@ public function __construct() $this->tempName = md5(time() . uniqid() . mt_rand()); $this->tempPath = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR; $this->javaPath = $this->tempPath . 'java' . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'java'; - $this->jSignPdfJarPath = $this->tempPath . 'jsignpdf' . DIRECTORY_SEPARATOR . 'JSignPdf.jar'; + $this->jSignPdfPath = $this->tempPath . 'jsignpdf'; $this->jSignPdfDownloadUrl = self::buildJSignPdfDownloadUrl(); } @@ -233,15 +233,15 @@ public function getJavaPath(): string return $this->javaPath; } - public function setjSignPdfJarPath(string $jSignPdfJarPath): self + public function setJSignPdfPath(string $jSignPdfPath): self { - $this->jSignPdfJarPath = $jSignPdfJarPath; + $this->jSignPdfPath = $jSignPdfPath; return $this; } - public function getjSignPdfJarPath(): string + public function getJSignPdfPath(): string { - return $this->jSignPdfJarPath; + return $this->jSignPdfPath; } public function isOutputTypeBase64(): bool diff --git a/src/Sign/JSignService.php b/src/Sign/JSignService.php index a867f5b..0aef70b 100644 --- a/src/Sign/JSignService.php +++ b/src/Sign/JSignService.php @@ -150,12 +150,12 @@ private function commandSign(JSignParam $params): string private function jSignPdfInvocation(JSignParam $params): string { - $jSignPdfPath = $this->getjSignPdfJarPath($params); - $libDir = JSignPdfRuntimeService::baseDir($jSignPdfPath) . '/lib'; + $jSignPdfPath = $this->getJSignPdfPath($params); + $libDir = $jSignPdfPath . '/lib'; if (is_dir($libDir)) { return '-classpath ' . escapeshellarg($libDir . '/*') . ' ' . self::MAIN_CLASS; } - return '-jar ' . escapeshellarg($jSignPdfPath); + return '-jar ' . escapeshellarg($jSignPdfPath . '/JSignPdf.jar'); } private function execWithPasswordsOnStdin(string $command, JSignParam $params): array @@ -190,7 +190,7 @@ private function javaCommand(JSignParam $params): string return $javaRuntimeService->getPath($params); } - private function getjSignPdfJarPath(JSignParam $params): string + private function getJSignPdfPath(JSignParam $params): string { $JsignPdfRuntimeService = new JSignPdfRuntimeService(); return $JsignPdfRuntimeService->getPath($params); diff --git a/tests/JSignPDFTest.php b/tests/JSignPDFTest.php index 13d2a8b..855d7ea 100644 --- a/tests/JSignPDFTest.php +++ b/tests/JSignPDFTest.php @@ -67,7 +67,7 @@ protected function tearDown(): void } } - private function withFakeRuntime(string $jSignPdfPath = 'vfs://download/jsignpdf'): JSignParam + private function withFakeRuntime(): JSignParam { $params = JSignParamBuilder::instance()->withDefault(); vfsStream::setup('download'); @@ -77,7 +77,8 @@ private function withFakeRuntime(string $jSignPdfPath = 'vfs://download/jsignpdf $params->setJavaPath('vfs://download/jvava/bin/java'); $params->setJavaDownloadUrl(''); mkdir('vfs://download/jsignpdf', 0755, true); - $params->setjSignPdfJarPath($jSignPdfPath); + touch('vfs://download/jsignpdf/JSignPdf.jar'); + $params->setJSignPdfPath('vfs://download/jsignpdf'); $params->setJSignPdfDownloadUrl(''); return $params; } @@ -115,7 +116,8 @@ public function testSignSuccess() $params->setJavaPath('vfs://download/jvava/bin/java'); $params->setJavaDownloadUrl(''); mkdir('vfs://download/jsignpdf', 0755, true); - $params->setjSignPdfJarPath('vfs://download/jsignpdf'); + touch('vfs://download/jsignpdf/JSignPdf.jar'); + $params->setJSignPdfPath('vfs://download/jsignpdf'); $params->setJSignPdfDownloadUrl(''); $params->setCertificate($this->getNewCert($params->getPassword())); $params->setPathPdfSigned('vfs://download/temp'); @@ -138,7 +140,8 @@ public function testSignUsingDifferentPasswords(string $password): void $params->setJavaPath('vfs://download/jvava/bin/java'); $params->setJavaDownloadUrl(''); mkdir('vfs://download/jsignpdf', 0755, true); - $params->setjSignPdfJarPath('vfs://download/jsignpdf'); + touch('vfs://download/jsignpdf/JSignPdf.jar'); + $params->setJSignPdfPath('vfs://download/jsignpdf'); $params->setJSignPdfDownloadUrl(''); $params->setCertificate($this->getNewCert($password)); $params->setPassword($password); @@ -170,7 +173,8 @@ public function testCertificateExpired() $params->setJavaPath('vfs://download/jvava/bin/java'); $params->setJavaDownloadUrl(''); mkdir('vfs://download/jsignpdf', 0755, true); - $params->setjSignPdfJarPath('vfs://download/jsignpdf'); + touch('vfs://download/jsignpdf/JSignPdf.jar'); + $params->setJSignPdfPath('vfs://download/jsignpdf'); $params->setJSignPdfDownloadUrl(''); $params->setCertificate($this->getNewCert('123', 0)); $params->setPassword('123'); @@ -199,7 +203,8 @@ public function testWithWhenResponseIsBase64() $params->setJavaPath('vfs://download/jvava/bin/java'); $params->setJavaDownloadUrl(''); mkdir('vfs://download/jsignpdf', 0755, true); - $params->setjSignPdfJarPath('vfs://download/jsignpdf'); + touch('vfs://download/jsignpdf/JSignPdf.jar'); + $params->setJSignPdfPath('vfs://download/jsignpdf'); $params->setJSignPdfDownloadUrl(''); $params->setCertificate($this->getNewCert('123')); $params->setPassword('123'); @@ -250,7 +255,7 @@ public function testJSignPDFNotFound() $this->expectExceptionMessageMatches('/JSignPDF not found/'); $params = JSignParamBuilder::instance()->withDefault(); $params->setJSignPdfDownloadUrl(''); - $params->setjSignPdfJarPath('invalid_path'); + $params->setJSignPdfPath('invalid_path'); $params->setCertificate($this->getNewCert($params->getPassword())); $params->setIsUseJavaInstalled(true); $this->service->getVersion($params); @@ -267,11 +272,12 @@ public function testGetVersion() touch('vfs://download/bin/java'); chmod('vfs://download/bin/java', 0755); mkdir('vfs://download/jsignpdf_fake_path/'); + touch('vfs://download/jsignpdf_fake_path/JSignPdf.jar'); touch('vfs://download/jsignpdf_fake_path/.jsignpdf_version_fake_url'); $params->setJavaPath('vfs://download/bin/java'); $params->setJSignPdfDownloadUrl('fake_url'); $params->setIsUseJavaInstalled(true); - $params->setjSignPdfJarPath('vfs://download/jsignpdf_fake_path'); + $params->setJSignPdfPath('vfs://download/jsignpdf_fake_path'); $version = $this->service->getVersion($params); $this->assertNotEmpty($version); } @@ -287,11 +293,12 @@ public function testGetVersionOfJSignPdf3(): void touch('vfs://download/bin/java'); chmod('vfs://download/bin/java', 0755); mkdir('vfs://download/jsignpdf_fake_path/'); + touch('vfs://download/jsignpdf_fake_path/JSignPdf.jar'); touch('vfs://download/jsignpdf_fake_path/.jsignpdf_version_fake_url'); $params->setJavaPath('vfs://download/bin/java'); $params->setJSignPdfDownloadUrl('fake_url'); $params->setIsUseJavaInstalled(true); - $params->setjSignPdfJarPath('vfs://download/jsignpdf_fake_path'); + $params->setJSignPdfPath('vfs://download/jsignpdf_fake_path'); $version = $this->service->getVersion($params); $this->assertEquals('3.1.0', $version); } @@ -351,8 +358,7 @@ public function testSignUsesTheFatJarWhenTheDistributionShipsOne(): void { global $mockExec, $mockProcCommand; $mockExec = ['Finished: Signature succesfully created.']; - $params = $this->withFakeRuntime('vfs://download/jsignpdf/JSignPdf.jar'); - touch('vfs://download/jsignpdf/JSignPdf.jar'); + $params = $this->withFakeRuntime(); $params->setCertificate($this->getNewCert($params->getPassword())); $params->setPathPdfSigned('vfs://download/temp'); file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); @@ -366,8 +372,7 @@ public function testSignPrefersTheClasspathOverALeftoverFatJar(): void { global $mockExec, $mockProcCommand; $mockExec = ['Finished: Signature succesfully created.']; - $params = $this->withFakeRuntime('vfs://download/jsignpdf/JSignPdf.jar'); - touch('vfs://download/jsignpdf/JSignPdf.jar'); + $params = $this->withFakeRuntime(); mkdir('vfs://download/jsignpdf/lib', 0755, true); $params->setCertificate($this->getNewCert($params->getPassword())); $params->setPathPdfSigned('vfs://download/temp'); @@ -427,7 +432,8 @@ public function testSignUsesTheClasspathWhenTheDistributionHasNoFatJar(): void { global $mockExec, $mockProcCommand; $mockExec = ['Finished: Signature succesfully created.']; - $params = $this->withFakeRuntime('vfs://download/jsignpdf/JSignPdf.jar'); + $params = $this->withFakeRuntime(); + unlink('vfs://download/jsignpdf/JSignPdf.jar'); mkdir('vfs://download/jsignpdf/lib', 0755, true); $params->setCertificate($this->getNewCert($params->getPassword())); $params->setPathPdfSigned('vfs://download/temp'); diff --git a/tests/Runtime/JSignPdfRuntimeServiceTest.php b/tests/Runtime/JSignPdfRuntimeServiceTest.php index 4175248..98e7fe5 100644 --- a/tests/Runtime/JSignPdfRuntimeServiceTest.php +++ b/tests/Runtime/JSignPdfRuntimeServiceTest.php @@ -47,9 +47,9 @@ public function testGetPathWithCustomAndValidJarPath(): void $jsignParam = new JSignParam(); $service = new JSignPdfRuntimeService(); touch($this->testTmpDir . '/JSignPdf.jar'); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir); $jsignParam->setJSignPdfDownloadUrl(''); - $this->assertEquals($this->testTmpDir . '/JSignPdf.jar', $service->getPath($jsignParam)); + $this->assertEquals($this->testTmpDir, $service->getPath($jsignParam)); } public function testGetPathWithoutFatJarButWithLibDirectory(): void @@ -57,16 +57,16 @@ public function testGetPathWithoutFatJarButWithLibDirectory(): void $jsignParam = new JSignParam(); $service = new JSignPdfRuntimeService(); mkdir($this->testTmpDir . '/lib'); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir); $jsignParam->setJSignPdfDownloadUrl(''); - $this->assertEquals($this->testTmpDir . '/JSignPdf.jar', $service->getPath($jsignParam)); + $this->assertEquals($this->testTmpDir, $service->getPath($jsignParam)); } public function testGetPathWhenNothingIsInstalled(): void { $jsignParam = new JSignParam(); $service = new JSignPdfRuntimeService(); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir); $jsignParam->setJSignPdfDownloadUrl(''); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageMatches('/JSignPDF not found/'); @@ -77,7 +77,7 @@ public function testGetPathWithoutJarPath(): void { $jsignParam = new JSignParam(); $service = new JSignPdfRuntimeService(); - $jsignParam->setjSignPdfJarPath(''); + $jsignParam->setJSignPdfPath(''); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageMatches('/Java not found/'); $service->getPath($jsignParam); @@ -92,7 +92,7 @@ public function testDownloadAndExtractDistributionWithFatJar(): void $jsignParam = new JSignParam(); $service = new JSignPdfRuntimeService(); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir . '/install'); $jsignParam->setJSignPdfDownloadUrl($url); $service->getPath($jsignParam); @@ -114,7 +114,7 @@ public function testDownloadAndExtractDistributionWithoutFatJar(): void $jsignParam = new JSignParam(); $service = new JSignPdfRuntimeService(); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir . '/install'); $jsignParam->setJSignPdfDownloadUrl($url); $service->getPath($jsignParam); @@ -141,7 +141,7 @@ public function testUpgradeFromADistributionWithFatJarRemovesTheOldJar(): void touch($this->testTmpDir . '/install/.jsignpdf_version_jsignpdf-2.3.0.zip'); $jsignParam = new JSignParam(); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir . '/install'); $jsignParam->setJSignPdfDownloadUrl($url); (new JSignPdfRuntimeService())->getPath($jsignParam); @@ -163,7 +163,7 @@ public function testUpgradeBetweenDistributionsWithoutFatJarReplacesTheLibDirect touch($this->testTmpDir . '/install/.jsignpdf_version_jsignpdf-3.1.0-minimal.zip'); $jsignParam = new JSignParam(); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir . '/install'); $jsignParam->setJSignPdfDownloadUrl($url); (new JSignPdfRuntimeService())->getPath($jsignParam); @@ -183,7 +183,7 @@ public function testDownloadAndExtractArchiveWithoutRootDirectoryEntry(): void unlink($zipPath); $jsignParam = new JSignParam(); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir . '/install'); $jsignParam->setJSignPdfDownloadUrl($url); (new JSignPdfRuntimeService())->getPath($jsignParam); @@ -198,7 +198,7 @@ public function testStagingDirectoryIsRemovedAfterTheInstall(): void unlink($zipPath); $jsignParam = new JSignParam(); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir . '/install'); $jsignParam->setJSignPdfDownloadUrl($url); (new JSignPdfRuntimeService())->getPath($jsignParam); @@ -213,17 +213,17 @@ public function testDownloadIsSkippedWhenTheInstalledVersionMatches(): void mkdir($this->testTmpDir . '/lib'); touch($this->testTmpDir . '/.jsignpdf_version_jsignpdf-3.1.0-minimal.zip'); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir); $jsignParam->setJSignPdfDownloadUrl('https://fake.url/jsignpdf-3.1.0-minimal.zip'); - $this->assertEquals($this->testTmpDir . '/JSignPdf.jar', $service->getPath($jsignParam)); + $this->assertEquals($this->testTmpDir, $service->getPath($jsignParam)); } public function testDownloadWithInvalidUrl(): void { $jsignParam = new JSignParam(); $service = new JSignPdfRuntimeService(); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir . '/install'); $jsignParam->setJSignPdfDownloadUrl('invalid_url'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageMatches('/url.*invalid/'); @@ -238,7 +238,7 @@ public function testDownloadWithInvalidZipFile(): void $jsignParam = new JSignParam(); $service = new JSignPdfRuntimeService(); - $jsignParam->setjSignPdfJarPath($this->testTmpDir . '/install/JSignPdf.jar'); + $jsignParam->setJSignPdfPath($this->testTmpDir . '/install'); $jsignParam->setJSignPdfDownloadUrl($server->getServerRoot() . '/jsignpdf.zip'); $this->expectException(InvalidArgumentException::class); From a1e3a817745eb5b20fe221df7632d413f2c4cfb2 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Thu, 3 Sep 2026 10:32:39 -0400 Subject: [PATCH 13/15] feat: drop the string form of setJSignParameters and add addJSignParameters Signed-off-by: YvesCesar --- AGENTS.md | 2 +- README.md | 19 ++++++++----------- example/index.php | 2 +- src/Sign/JSignParam.php | 28 ++++++++++++++++++++-------- tests/JSignPDFTest.php | 27 ++++++++++++++++++++++++--- 5 files changed, 54 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6db2137..1807882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ JSignPdf 3.x is the supported target and needs a Java 21+ runtime. Two distribut ## Constraints -Everything reaching a shell must go through `escapeshellarg()`. Secrets must never be passed through argv — use stdin instead. JSignPdf reads a password from stdin when the option value is `-` and `--enable-stdin-passwords` is set. Every password option goes through it, not only the keystore one: values are read one line each, in the fixed order `-ksp`, `-kp`, `-opwd`, `-upwd`, `-tscp`, `-tsp`, so `JSignParam::getPasswords()` keeps that order and `JSignService` writes the lines in it. The only secret that can still reach argv is one written into the string form of `setJSignParameters()`, which is passed through unparsed. +Everything reaching a shell must go through `escapeshellarg()`. Secrets must never be passed through argv — use stdin instead. JSignPdf reads a password from stdin when the option value is `-` and `--enable-stdin-passwords` is set. Every password option goes through it, not only the keystore one: values are read one line each, in the fixed order `-ksp`, `-kp`, `-opwd`, `-upwd`, `-tscp`, `-tsp`, so `JSignParam::getPasswords()` keeps that order and `JSignService` writes the lines in it. `setJSignParameters()`/`addJSignParameters()` only accept a list of options and values, which the package escapes; there is no string form to bypass that. ## Testing patterns diff --git a/README.md b/README.md index d3f0b94..c2e596f 100644 --- a/README.md +++ b/README.md @@ -61,15 +61,16 @@ $param->setTempPath('/path/temp/to/sign/files/'); Change parameters of JSignPDF: ```php -$param->setJSignParameters("-a -kst PKCS12 -ts https://freetsa.org/tsr"); +$param->setJSignParameters(['-kst', 'PKCS12', '-ts', 'https://freetsa.org/tsr']); ``` -A string is passed to JSignPdf as written, so it is the one place where the -content is not escaped for you. Never build one from untrusted input. To let -the package escape the values, pass a list of options and values instead: +`setJSignParameters()` takes a list of options and values and replaces the +current ones; the package escapes every value for you. Use +`addJSignParameters()` to add more options without reading the current ones +first: ```php -$param->setJSignParameters(['-kst', 'PKCS12', '-ts', 'https://freetsa.org/tsr']); +$param->addJSignParameters(['-ha', 'SHA512']); ``` ## Passwords @@ -88,16 +89,12 @@ $param->setTsaCertPassword('tsa cert password'); // -tscp $param->setTsaPassword('tsa password'); // -tsp ``` -Passing one of those options to `setJSignParameters()` as a list works too, and -the value is taken out of the command line just the same: +Passing one of those options to `setJSignParameters()` or `addJSignParameters()` works too, and the value is taken out of the command line just the same: ```php $param->setJSignParameters(['-ts', 'https://freetsa.org/tsr', '-ta', 'PASSWORD', '-tsu', 'jhon', '-tsp', 'tsa password']); ``` -The string form is the exception, as nothing in it is parsed: a password -written there does reach the command line. Use a setter or a list for it. - ## JSignPdf 3.x This package targets JSignPdf 3.x, which needs a Java 21+ runtime. JSignPdf 2.x @@ -117,7 +114,7 @@ parameters. To sign such a file, either turn off the append mode with `--overwrite` or pick an algorithm the PDF version supports: ```php -$param->setJSignParameters('-kst PKCS12 --overwrite'); +$param->setJSignParameters(['-kst', 'PKCS12', '--overwrite']); ``` The `-a` flag is kept by JSignPdf 3.1 as a no-op. diff --git a/example/index.php b/example/index.php index 5492430..4f52249 100644 --- a/example/index.php +++ b/example/index.php @@ -30,7 +30,7 @@ $param->setCertificate($pfxCertificateContent); $param->setPdf(file_get_contents(__DIR__ . '/../tests/resources/pdf-test.pdf')); $param->setPassword($password); -$param->setJSignParameters('-kst PKCS12 --overwrite'); +$param->setJSignParameters(['-kst', 'PKCS12', '--overwrite']); $jSignPdf = new JSignPDF($param); $fileSigned = $jSignPdf->sign(); diff --git a/src/Sign/JSignParam.php b/src/Sign/JSignParam.php index 2c2302e..d854f4b 100644 --- a/src/Sign/JSignParam.php +++ b/src/Sign/JSignParam.php @@ -18,11 +18,15 @@ class JSignParam private const JSIGNPDF_VERSION = '3.1.0'; + /** @var list */ + private const DEFAULT_JSIGN_PARAMETERS = ['-a', '-kst', 'PKCS12']; + private string $pdf = ''; private string $certificate = ''; private string $password = ''; private string $pathPdfSigned = ''; - private string $JSignParameters = "-a -kst PKCS12"; + /** @var list */ + private array $jSignParameters = self::DEFAULT_JSIGN_PARAMETERS; private bool $isUseJavaInstalled = false; private string $javaPath = ''; private string $tempPath = ''; @@ -102,19 +106,27 @@ public function setPathPdfSigned(string $pathPdfSigned): self public function getJSignParameters(): string { - return $this->JSignParameters; + return implode(' ', array_map('escapeshellarg', $this->jSignParameters)); } /** - * @param string|list $JSignParameters + * @param list $parameters */ - public function setJSignParameters(string|array $JSignParameters): self + public function setJSignParameters(array $parameters): self { $this->parameterPasswords = []; - if (is_array($JSignParameters)) { - $JSignParameters = implode(' ', array_map('escapeshellarg', $this->takePasswords($JSignParameters))); - } - $this->JSignParameters = $JSignParameters; + $this->jSignParameters = $this->takePasswords($parameters); + return $this; + } + + /** + * Adds to the current parameters instead of replacing them. + * + * @param list $parameters + */ + public function addJSignParameters(array $parameters): self + { + $this->jSignParameters = array_merge($this->jSignParameters, $this->takePasswords($parameters)); return $this; } diff --git a/tests/JSignPDFTest.php b/tests/JSignPDFTest.php index 855d7ea..c431b81 100644 --- a/tests/JSignPDFTest.php +++ b/tests/JSignPDFTest.php @@ -413,19 +413,22 @@ public function testSignEscapesOptionValuesGivenAsAList(): void ); } - public function testSignKeepsOptionsGivenAsAStringUntouched(): void + public function testAddJSignParametersAppendsToTheDefaultOptions(): void { global $mockExec, $mockProcCommand; $mockExec = ['Finished: Signature succesfully created.']; $params = $this->withFakeRuntime(); - $params->setJSignParameters('-kst PKCS12 -ts https://freetsa.org/tsr'); + $params->addJSignParameters(['-ha', 'SHA512']); $params->setCertificate($this->getNewCert($params->getPassword())); $params->setPathPdfSigned('vfs://download/temp'); file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); $this->service->sign($params); - $this->assertStringContainsString('-kst PKCS12 -ts https://freetsa.org/tsr', $mockProcCommand); + $this->assertStringContainsString( + implode(' ', array_map('escapeshellarg', ['-a', '-kst', 'PKCS12', '-ha', 'SHA512'])), + $mockProcCommand + ); } public function testSignUsesTheClasspathWhenTheDistributionHasNoFatJar(): void @@ -575,6 +578,24 @@ public function testSignForgetsThePasswordsOfAReplacedOptionList(): void $this->assertEquals($params->getPassword() . PHP_EOL, file_get_contents($mockProcStdinFile)); } + public function testAddJSignParametersKeepsThePasswordsAlreadySetThroughTheOptionList(): void + { + global $mockExec, $mockProcCommand, $mockProcStdinFile; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setJSignParameters(['-tsp', 'tsa secret']); + $params->addJSignParameters(['-kst', 'PKCS12']); + + $this->signWithFakeRuntime($params); + + $this->assertStringContainsString('--enable-stdin-passwords -ksp - -tsp - ', $mockProcCommand); + $this->assertStringNotContainsString('tsa secret', $mockProcCommand); + $this->assertEquals( + $params->getPassword() . PHP_EOL . 'tsa secret' . PHP_EOL, + file_get_contents($mockProcStdinFile) + ); + } + public function testSignKeepsAPasswordAlreadyMarkedAsReadFromStdin(): void { global $mockExec, $mockProcCommand, $mockProcStdinFile; From b274df01f0dcba3e281b646f94a1224e48cab126 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Thu, 3 Sep 2026 10:42:15 -0400 Subject: [PATCH 14/15] feat: add setJavaOptions and setEnvironmentVariables to keep javaPath a plain executable Signed-off-by: YvesCesar --- AGENTS.md | 4 +-- README.md | 10 +++++++ src/Sign/JSignParam.php | 40 +++++++++++++++++++++++++++ src/Sign/JSignService.php | 17 ++++++++++-- tests/JSignPDFTest.php | 58 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 120 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1807882..fa8c9b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,13 +41,13 @@ JSignPdf 3.x is the supported target and needs a Java 21+ runtime. Two distribut ## Constraints -Everything reaching a shell must go through `escapeshellarg()`. Secrets must never be passed through argv — use stdin instead. JSignPdf reads a password from stdin when the option value is `-` and `--enable-stdin-passwords` is set. Every password option goes through it, not only the keystore one: values are read one line each, in the fixed order `-ksp`, `-kp`, `-opwd`, `-upwd`, `-tscp`, `-tsp`, so `JSignParam::getPasswords()` keeps that order and `JSignService` writes the lines in it. `setJSignParameters()`/`addJSignParameters()` only accept a list of options and values, which the package escapes; there is no string form to bypass that. +Everything reaching a shell must go through `escapeshellarg()`. Secrets must never be passed through argv — use stdin instead. JSignPdf reads a password from stdin when the option value is `-` and `--enable-stdin-passwords` is set. Every password option goes through it, not only the keystore one: values are read one line each, in the fixed order `-ksp`, `-kp`, `-opwd`, `-upwd`, `-tscp`, `-tsp`, so `JSignParam::getPasswords()` keeps that order and `JSignService` writes the lines in it. `setJSignParameters()`/`addJSignParameters()` only accept a list of options and values, which the package escapes; there is no string form to bypass that. `setJavaPath()` takes only the `java` executable path; JVM options and environment variables for the process that runs it have their own setters (`setJavaOptions()`, `setEnvironmentVariables()`) instead of being folded into the path. ## Testing patterns - `tests/` mirrors the `src/` tree. Preserve the same relative path and append `Test` to the source class name. For example, `src/Runtime/JavaRuntimeService.php` is covered by `tests/Runtime/JavaRuntimeServiceTest.php`. `tests/Builder/`, `tests/resources/` and `tests/Integration/` are examples of support directories outside this mirror. - `tests/Integration/` holds the tests that run the real JSignPdf. They are tagged with `#[Group('integration')]`, excluded from `test:unit` and run by `test:integration`. They need network access and download the JRE and JSignPdf into `tmp/` on the first run. CI runs them in their own job, separate from the PHP version matrix. -- Shell calls are covered by declaring `exec()`, `proc_open()` and `proc_close()` functions inside the tested namespace, shadowing the global ones for that file, driven by a `$mockExec` global set per test (see `tests/JSignPDFTest.php`). The `proc_open()` shadow records the command in `$mockProcCommand` and writes the stdin it receives to `$mockProcStdinFile`, so tests can assert both what was passed as arguments and what was kept out of them. +- Shell calls are covered by declaring `exec()`, `proc_open()` and `proc_close()` functions inside the tested namespace, shadowing the global ones for that file, driven by a `$mockExec` global set per test (see `tests/JSignPDFTest.php`). The `proc_open()` shadow records the command in `$mockProcCommand`, the environment it received in `$mockProcEnv`, and writes the stdin it receives to `$mockProcStdinFile`, so tests can assert both what was passed as arguments and what was kept out of them. - `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar http download endpoints. - `tests/Builder/JSignParamBuilder::withDefault()` returns a `JSignParam` preloaded with `tests/resources/certificado.pfx` (password `123`) and `tests/resources/pdf-test.pdf`. - Psalm's baseline is `tests/psalm-baseline.xml` with `findUnusedBaselineEntry` on — removing an error means the baseline entry must go too and is updated by `composer psalm:update-baseline`. diff --git a/README.md b/README.md index c2e596f..d138dfd 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,16 @@ With standalone Java: $param->setJavaPath('/path/to/bin/java'); ``` +`setJavaPath()` takes only the path to the `java` executable. Applications +that need JVM options or environment variables — for example a self-managed +JSignPdf install that needs `-Duser.home` and `JSIGNPDF_HOME` — set them +separately: + +```php +$param->setJavaOptions(['-Duser.home=/tmp/jsignpdf-home']); +$param->setEnvironmentVariables(['JSIGNPDF_HOME' => '/tmp/jsignpdf-home']); +``` + With JSignPDF bin: ```php $param->setJSignPdfPath('/path/to/jsignpdf'); diff --git a/src/Sign/JSignParam.php b/src/Sign/JSignParam.php index d854f4b..0c16e28 100644 --- a/src/Sign/JSignParam.php +++ b/src/Sign/JSignParam.php @@ -29,6 +29,10 @@ class JSignParam private array $jSignParameters = self::DEFAULT_JSIGN_PARAMETERS; private bool $isUseJavaInstalled = false; private string $javaPath = ''; + /** @var list */ + private array $javaOptions = []; + /** @var array */ + private array $environmentVariables = []; private string $tempPath = ''; private string $tempName = ''; private bool $isOutputTypeBase64 = false; @@ -245,6 +249,42 @@ public function getJavaPath(): string return $this->javaPath; } + /** + * JVM options for the java command, kept out of javaPath so it stays a + * plain executable path (e.g. `-Duser.home=/tmp/jsignpdf-home`). + * + * @param list $javaOptions + */ + public function setJavaOptions(array $javaOptions): self + { + $this->javaOptions = $javaOptions; + return $this; + } + + /** @return list */ + public function getJavaOptions(): array + { + return $this->javaOptions; + } + + /** + * Environment variables for the process that runs JSignPdf (e.g. + * `JSIGNPDF_HOME`). + * + * @param array $environmentVariables + */ + public function setEnvironmentVariables(array $environmentVariables): self + { + $this->environmentVariables = $environmentVariables; + return $this; + } + + /** @return array */ + public function getEnvironmentVariables(): array + { + return $this->environmentVariables; + } + public function setJSignPdfPath(string $jSignPdfPath): self { $this->jSignPdfPath = $jSignPdfPath; diff --git a/src/Sign/JSignService.php b/src/Sign/JSignService.php index 0aef70b..bbe015f 100644 --- a/src/Sign/JSignService.php +++ b/src/Sign/JSignService.php @@ -84,7 +84,7 @@ public function getVersion(JSignParam $params): string $java = escapeshellarg($this->javaCommand($params)); $jSignPdf = $this->jSignPdfInvocation($params); - $command = "$java $jSignPdf --version 2>&1"; + $command = implode(' ', array_merge([$java], $this->javaOptions($params), [$jSignPdf, '--version'])) . ' 2>&1'; exec($command, $output); $lastRow = end($output); if (empty($output) || strpos($lastRow, 'version') === false) { @@ -139,13 +139,22 @@ private function commandSign(JSignParam $params): string $pdf = escapeshellarg($pdf); $certificate = escapeshellarg($certificate); $pathPdfSigned = escapeshellarg($params->getPathPdfSigned()); + $javaOptions = implode(' ', array_merge(['-Duser.language=en'], $this->javaOptions($params))); $passwords = ''; foreach (array_keys($params->getPasswords()) as $option) { $passwords .= "$option - "; } - return "$java -Duser.language=en $jSignPdf $pdf -ksf $certificate --enable-stdin-passwords -ksp - {$passwords}{$params->getJSignParameters()} -d $pathPdfSigned 2>&1"; + return "$java $javaOptions $jSignPdf $pdf -ksf $certificate --enable-stdin-passwords -ksp - {$passwords}{$params->getJSignParameters()} -d $pathPdfSigned 2>&1"; + } + + /** + * @return list + */ + private function javaOptions(JSignParam $params): array + { + return array_map('escapeshellarg', $params->getJavaOptions()); } private function jSignPdfInvocation(JSignParam $params): string @@ -166,7 +175,9 @@ private function execWithPasswordsOnStdin(string $command, JSignParam $params): 1 => ['pipe', 'w'], ]; $pipes = []; - $process = proc_open($command, $descriptors, $pipes); + $environmentVariables = $params->getEnvironmentVariables(); + $env = $environmentVariables === [] ? null : array_merge(getenv() ?: [], $environmentVariables); + $process = proc_open($command, $descriptors, $pipes, null, $env); if (!is_resource($process)) { throw new Exception('Error to sign PDF.'); } diff --git a/tests/JSignPDFTest.php b/tests/JSignPDFTest.php index c431b81..ce1c029 100644 --- a/tests/JSignPDFTest.php +++ b/tests/JSignPDFTest.php @@ -12,13 +12,14 @@ function exec(string $command, ?array &$output = null, ?int &$return_var = null) return \exec($command, $output, $return_var); } -function proc_open(string $command, array $descriptor_spec, ?array &$pipes) +function proc_open(string $command, array $descriptor_spec, ?array &$pipes, ?string $cwd = null, ?array $env_vars = null) { - global $mockExec, $mockProcCommand, $mockProcStdinFile; + global $mockExec, $mockProcCommand, $mockProcStdinFile, $mockProcEnv; if (!$mockExec) { - return \proc_open($command, $descriptor_spec, $pipes); + return \proc_open($command, $descriptor_spec, $pipes, $cwd, $env_vars); } $mockProcCommand = $command; + $mockProcEnv = $env_vars; $mockProcStdinFile = tempnam(sys_get_temp_dir(), 'jsignpdf_stdin_'); $stdout = fopen('php://memory', 'w+'); fwrite($stdout, implode(PHP_EOL, $mockExec)); @@ -52,10 +53,11 @@ class JSignPDFTest extends TestCase protected function setUp(): void { - global $mockExec, $mockProcCommand, $mockProcStdinFile; + global $mockExec, $mockProcCommand, $mockProcStdinFile, $mockProcEnv; $mockExec = null; $mockProcCommand = null; $mockProcStdinFile = null; + $mockProcEnv = null; $this->service = new JSignService(); } @@ -354,6 +356,54 @@ public function testSignEscapesEveryPathOfTheCommand(): void $this->assertStringContainsString('-d ' . escapeshellarg($params->getPathPdfSigned()), $mockProcCommand); } + public function testSignPassesCustomJavaOptionsToTheJvm(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setJavaOptions(['-Duser.home=/tmp/jsignpdf-home']); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertStringContainsString( + '-Duser.language=en ' . escapeshellarg('-Duser.home=/tmp/jsignpdf-home'), + $mockProcCommand + ); + } + + public function testSignDoesNotOverrideTheEnvironmentByDefault(): void + { + global $mockExec, $mockProcEnv; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertNull($mockProcEnv); + } + + public function testSignPassesEnvironmentVariablesToTheProcess(): void + { + global $mockExec, $mockProcEnv; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $params->setEnvironmentVariables(['JSIGNPDF_HOME' => '/tmp/jsignpdf-home']); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $this->service->sign($params); + + $this->assertIsArray($mockProcEnv); + $this->assertSame('/tmp/jsignpdf-home', $mockProcEnv['JSIGNPDF_HOME']); + } + public function testSignUsesTheFatJarWhenTheDistributionShipsOne(): void { global $mockExec, $mockProcCommand; From b4da77acdaa5634563cbb04ee45fca6a18b4a82a Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Thu, 3 Sep 2026 10:51:00 -0400 Subject: [PATCH 15/15] fix: use the JSignPdf exit code instead of matching its success message Signed-off-by: YvesCesar --- AGENTS.md | 2 +- src/Sign/JSignService.php | 16 ++++++++-------- tests/JSignPDFTest.php | 37 +++++++++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa8c9b9..74ef548 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Everything reaching a shell must go through `escapeshellarg()`. Secrets must nev - `tests/` mirrors the `src/` tree. Preserve the same relative path and append `Test` to the source class name. For example, `src/Runtime/JavaRuntimeService.php` is covered by `tests/Runtime/JavaRuntimeServiceTest.php`. `tests/Builder/`, `tests/resources/` and `tests/Integration/` are examples of support directories outside this mirror. - `tests/Integration/` holds the tests that run the real JSignPdf. They are tagged with `#[Group('integration')]`, excluded from `test:unit` and run by `test:integration`. They need network access and download the JRE and JSignPdf into `tmp/` on the first run. CI runs them in their own job, separate from the PHP version matrix. -- Shell calls are covered by declaring `exec()`, `proc_open()` and `proc_close()` functions inside the tested namespace, shadowing the global ones for that file, driven by a `$mockExec` global set per test (see `tests/JSignPDFTest.php`). The `proc_open()` shadow records the command in `$mockProcCommand`, the environment it received in `$mockProcEnv`, and writes the stdin it receives to `$mockProcStdinFile`, so tests can assert both what was passed as arguments and what was kept out of them. +- Shell calls are covered by declaring `exec()`, `proc_open()` and `proc_close()` functions inside the tested namespace, shadowing the global ones for that file, driven by a `$mockExec` global set per test (see `tests/JSignPDFTest.php`). The `proc_open()` shadow records the command in `$mockProcCommand`, the environment it received in `$mockProcEnv`, and writes the stdin it receives to `$mockProcStdinFile`; the `proc_close()` shadow returns `$mockProcExitCode` (0 by default), so tests can assert both what was passed as arguments and what was kept out of them, and simulate a failing exit code. - `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar http download endpoints. - `tests/Builder/JSignParamBuilder::withDefault()` returns a `JSignParam` preloaded with `tests/resources/certificado.pfx` (password `123`) and `tests/resources/pdf-test.pdf`. - Psalm's baseline is `tests/psalm-baseline.xml` with `findUnusedBaselineEntry` on — removing an error means the baseline entry must go too and is updated by `composer psalm:update-baseline`. diff --git a/src/Sign/JSignService.php b/src/Sign/JSignService.php index bbe015f..1dfacb5 100644 --- a/src/Sign/JSignService.php +++ b/src/Sign/JSignService.php @@ -29,16 +29,13 @@ public function sign(JSignParam $params): string $this->validation($params); $commandSign = $this->commandSign($params); - $output = $this->execWithPasswordsOnStdin($commandSign, $params); + [$output, $exitCode] = $this->execWithPasswordsOnStdin($commandSign, $params); - $out = json_encode($output); + $out = json_encode($output); if ($out === false) { throw new Exception('Error to sign PDF.'); } - $messageSuccess = "Finished: Signature succesfully created."; - $isSigned = strpos($out, $messageSuccess) !== false; - - $this->throwIf(!$isSigned, "Error to sign PDF. $out"); + $this->throwIf($exitCode !== 0, "Error to sign PDF. $out"); $fileSigned = $this->fileService->contentFile( $params->getTempPdfSignedPath(), @@ -167,6 +164,9 @@ private function jSignPdfInvocation(JSignParam $params): string return '-jar ' . escapeshellarg($jSignPdfPath . '/JSignPdf.jar'); } + /** + * @psalm-return list{list, int} + */ private function execWithPasswordsOnStdin(string $command, JSignParam $params): array { $passwords = array_merge([$params->getPassword()], array_values($params->getPasswords())); @@ -190,9 +190,9 @@ private function execWithPasswordsOnStdin(string $command, JSignParam $params): } $output = stream_get_contents($pipes[1]); fclose($pipes[1]); - proc_close($process); + $exitCode = proc_close($process); - return explode(PHP_EOL, rtrim((string) $output, PHP_EOL)); + return [explode(PHP_EOL, rtrim((string) $output, PHP_EOL)), $exitCode]; } private function javaCommand(JSignParam $params): string diff --git a/tests/JSignPDFTest.php b/tests/JSignPDFTest.php index ce1c029..0f16c79 100644 --- a/tests/JSignPDFTest.php +++ b/tests/JSignPDFTest.php @@ -30,8 +30,8 @@ function proc_open(string $command, array $descriptor_spec, ?array &$pipes, ?str function proc_close($process) { - global $mockExec; - return $mockExec ? 0 : \proc_close($process); + global $mockExec, $mockProcExitCode; + return $mockExec ? $mockProcExitCode ?? 0 : \proc_close($process); } namespace Jeidison\JSignPDF\Tests; @@ -53,11 +53,12 @@ class JSignPDFTest extends TestCase protected function setUp(): void { - global $mockExec, $mockProcCommand, $mockProcStdinFile, $mockProcEnv; + global $mockExec, $mockProcCommand, $mockProcStdinFile, $mockProcEnv, $mockProcExitCode; $mockExec = null; $mockProcCommand = null; $mockProcStdinFile = null; $mockProcEnv = null; + $mockProcExitCode = null; $this->service = new JSignService(); } @@ -307,11 +308,12 @@ public function testGetVersionOfJSignPdf3(): void public function testSignWhenJSignPdfReportsAFailure(): void { - global $mockExec; + global $mockExec, $mockProcExitCode; $mockExec = [ 'INFO Creating signature', 'INFO Finished: Creating of signature failed.', ]; + $mockProcExitCode = 4; $params = $this->withFakeRuntime(); $params->setCertificate($this->getNewCert($params->getPassword())); $params->setPathPdfSigned('vfs://download/temp'); @@ -320,6 +322,33 @@ public function testSignWhenJSignPdfReportsAFailure(): void $this->service->sign($params); } + public function testSignSucceedsBasedOnTheExitCodeNotOnTheOutputText(): void + { + global $mockExec; + $mockExec = ['some unrelated log line, no success message here']; + $params = $this->withFakeRuntime(); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + file_put_contents($params->getTempPdfSignedPath(), 'signed file content'); + + $fileSignedContent = $this->service->sign($params); + + $this->assertEquals('signed file content', $fileSignedContent); + } + + public function testSignFailsBasedOnTheExitCodeEvenWithoutAFailureMessage(): void + { + global $mockExec, $mockProcExitCode; + $mockExec = ['some unrelated log line, no failure message here']; + $mockProcExitCode = 1; + $params = $this->withFakeRuntime(); + $params->setCertificate($this->getNewCert($params->getPassword())); + $params->setPathPdfSigned('vfs://download/temp'); + + $this->expectExceptionMessageMatches('/Error to sign PDF/'); + $this->service->sign($params); + } + public function testSignSendsThePasswordThroughStdinAndNotThroughArgv(): void { global $mockExec, $mockProcCommand, $mockProcStdinFile;