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 6bda044..74ef548 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 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/` 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. 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`; 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/README.md b/README.md index 76338a9..d138dfd 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,19 @@ 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->setjSignPdfJarPath('/path/to/jsignpdf'); +$param->setJSignPdfPath('/path/to/jsignpdf'); ``` With specific Java or JSignPdf version: ```php @@ -61,9 +71,64 @@ $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']); +``` + +`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->addJSignParameters(['-ha', 'SHA512']); +``` + +## 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()` 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']); +``` + +## JSignPdf 3.x + +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 `setJSignPdfPath()` 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 + 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 +149,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 diff --git a/composer.json b/composer.json index 62e668d..816b439 100644 --- a/composer.json +++ b/composer.json @@ -39,8 +39,9 @@ "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:coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit", + "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 --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/example/index.php b/example/index.php index dca3118..4f52249 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..75888a8 100644 --- a/src/Runtime/JSignPdfRuntimeService.php +++ b/src/Runtime/JSignPdfRuntimeService.php @@ -13,28 +13,24 @@ class JSignPdfRuntimeService { public function getPath(JSignParam $params): string { - $jsignPdfPath = $params->getjSignPdfJarPath(); + $jsignPdfPath = $params->getJSignPdfPath(); $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); + throw new InvalidArgumentException('JSignPDF not found on path: '. $jsignPdfPath); } if ($downloadUrl && $jsignPdfPath) { - $baseDir = preg_replace('/\/JSignPdf.jar$/', '', $jsignPdfPath); - if (!is_string($baseDir)) { - throw new InvalidArgumentException('Invalid JsignParamPath'); - } - 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 (!file_exists($jsignPdfPath) || !self::validateVersion($params)) { + if (!self::isInstalled($jsignPdfPath) || !self::validateVersion($params)) { self::downloadAndExtract($params); } return $jsignPdfPath; @@ -43,22 +39,23 @@ public function getPath(JSignParam $params): string throw new InvalidArgumentException('Java not found.'); } + private static function isInstalled(string $jsignPdfPath): bool + { + return is_dir($jsignPdfPath . '/lib') || file_exists($jsignPdfPath . '/JSignPdf.jar'); + } + private function validateVersion(JSignParam $params): bool { - $jsignPdfPath = $params->getjSignPdfJarPath(); - $versionCacheFile = $jsignPdfPath . '/.jsignpdf_version_' . basename($params->getJSignPdfDownloadUrl()); + $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 = preg_replace('/\/JSignPdf.jar$/', '', $jsignPdfPath); - if (!is_string($baseDir)) { - throw new InvalidArgumentException('Invalid JsignParamPath'); - } if (!is_dir($baseDir)) { $ok = mkdir($baseDir, 0755, true); if (!$ok) { @@ -69,25 +66,77 @@ 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'); } - $ok = $z->extractTo(pathto: $baseDir, files: [$z->getNameIndex(0) . 'JSignPdf.jar']); + $staging = $baseDir . '/.jsignpdf_staging_' . uniqid(); + $ok = $zip->extractTo($staging); + $zip->close(); if ($ok !== true) { - throw new InvalidArgumentException('JSignPdf.jar not found inside path: ' . $z->getNameIndex(0) . 'JSignPdf.jar'); + $this->deletePath($staging); + 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('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'); + try { + $this->replaceInstall($this->archiveRoot($staging), $baseDir); + } finally { + $this->deletePath($staging); + } + foreach (glob($baseDir . '/.jsignpdf_version_*') ?: [] as $previousVersion) { + unlink($previousVersion); + } + if (!self::isInstalled($baseDir)) { + 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/src/Sign/JSignParam.php b/src/Sign/JSignParam.php index 52e0c77..0c16e28 100644 --- a/src/Sign/JSignParam.php +++ b/src/Sign/JSignParam.php @@ -7,26 +7,56 @@ */ 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 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 = ''; + /** @var list */ + private array $javaOptions = []; + /** @var array */ + private array $environmentVariables = []; 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 = 'https://github.com/intoolswetrust/jsignpdf/releases/download/JSignPdf_2_3_0/jsignpdf-2.3.0.zip'; + private string $jSignPdfDownloadUrl = ''; + /** @var array */ + private array $passwords = []; + /** @var array */ + private array $parameterPasswords = []; 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(); + } + + 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 @@ -80,15 +110,107 @@ public function setPathPdfSigned(string $pathPdfSigned): self public function getJSignParameters(): string { - return $this->JSignParameters; + return implode(' ', array_map('escapeshellarg', $this->jSignParameters)); + } + + /** + * @param list $parameters + */ + public function setJSignParameters(array $parameters): self + { + $this->parameterPasswords = []; + $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; + } + + 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 setJSignParameters(string $JSignParameters): self + public function setTsaPassword(string $password): self { - $this->JSignParameters = $JSignParameters; + 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; @@ -127,15 +249,51 @@ public function getJavaPath(): string return $this->javaPath; } - public function setjSignPdfJarPath(string $jSignPdfJarPath): self + /** + * 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->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 ce92851..1dfacb5 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,16 +29,13 @@ public function sign(JSignParam $params): string $this->validation($params); $commandSign = $this->commandSign($params); - exec($commandSign, $output); + [$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(), @@ -79,11 +78,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 = implode(' ', array_merge([$java], $this->javaOptions($params), [$jSignPdf, '--version'])) . ' 2>&1'; exec($command, $output); $lastRow = end($output); if (empty($output) || strpos($lastRow, 'version') === false) { @@ -133,11 +131,68 @@ 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()); + $javaOptions = implode(' ', array_merge(['-Duser.language=en'], $this->javaOptions($params))); + + $passwords = ''; + foreach (array_keys($params->getPasswords()) as $option) { + $passwords .= "$option - "; + } + + 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 + { + $jSignPdfPath = $this->getJSignPdfPath($params); + $libDir = $jSignPdfPath . '/lib'; + if (is_dir($libDir)) { + return '-classpath ' . escapeshellarg($libDir . '/*') . ' ' . self::MAIN_CLASS; + } + 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())); + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + ]; + $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.'); + } + $written = fwrite($pipes[0], implode(PHP_EOL, $passwords) . 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]); + $exitCode = 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)), $exitCode]; } private function javaCommand(JSignParam $params): string @@ -146,7 +201,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/Integration/SignPdfTest.php b/tests/Integration/SignPdfTest.php new file mode 100644 index 0000000..ba0d839 --- /dev/null +++ b/tests/Integration/SignPdfTest.php @@ -0,0 +1,92 @@ + 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 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(); + $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 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/'); + JSignPDF::instance($this->params())->sign(); + } +} diff --git a/tests/JSignPDFTest.php b/tests/JSignPDFTest.php index 5450be9..0f16c79 100644 --- a/tests/JSignPDFTest.php +++ b/tests/JSignPDFTest.php @@ -12,10 +12,33 @@ 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, ?string $cwd = null, ?array $env_vars = null) +{ + global $mockExec, $mockProcCommand, $mockProcStdinFile, $mockProcEnv; + if (!$mockExec) { + 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)); + rewind($stdout); + $pipes = [fopen($mockProcStdinFile, 'w'), $stdout]; + return $stdout; +} + +function proc_close($process) +{ + global $mockExec, $mockProcExitCode; + return $mockExec ? $mockProcExitCode ?? 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 +53,39 @@ class JSignPDFTest extends TestCase protected function setUp(): void { - global $mockExec; + global $mockExec, $mockProcCommand, $mockProcStdinFile, $mockProcEnv, $mockProcExitCode; $mockExec = null; + $mockProcCommand = null; + $mockProcStdinFile = null; + $mockProcEnv = null; + $mockProcExitCode = null; $this->service = new JSignService(); } + protected function tearDown(): void + { + global $mockProcStdinFile; + if ($mockProcStdinFile && is_file($mockProcStdinFile)) { + unlink($mockProcStdinFile); + } + } + + private function withFakeRuntime(): 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); + touch('vfs://download/jsignpdf/JSignPdf.jar'); + $params->setJSignPdfPath('vfs://download/jsignpdf'); + $params->setJSignPdfDownloadUrl(''); + return $params; + } + private function getNewCert($password, $expireDays = 365) { $privateKey = openssl_pkey_new([ @@ -68,7 +119,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'); @@ -91,7 +143,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); @@ -123,7 +176,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'); @@ -152,7 +206,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'); @@ -203,7 +258,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); @@ -220,12 +275,416 @@ 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); } + + 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.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->setJSignPdfPath('vfs://download/jsignpdf_fake_path'); + $version = $this->service->getVersion($params); + $this->assertEquals('3.1.0', $version); + } + + public function testSignWhenJSignPdfReportsAFailure(): void + { + 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'); + + $this->expectExceptionMessageMatches('/Creating of signature failed/'); + $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; + $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 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; + $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->assertStringContainsString('-jar ' . escapeshellarg('vfs://download/jsignpdf/JSignPdf.jar'), $mockProcCommand); + } + + public function testSignPrefersTheClasspathOverALeftoverFatJar(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + 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; + $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 testAddJSignParametersAppendsToTheDefaultOptions(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $params = $this->withFakeRuntime(); + $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( + implode(' ', array_map('escapeshellarg', ['-a', '-kst', 'PKCS12', '-ha', 'SHA512'])), + $mockProcCommand + ); + } + + public function testSignUsesTheClasspathWhenTheDistributionHasNoFatJar(): void + { + global $mockExec, $mockProcCommand; + $mockExec = ['Finished: Signature succesfully created.']; + $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'); + 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 + ); + } + + 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 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; + $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)); + } } diff --git a/tests/Runtime/JSignPdfRuntimeServiceTest.php b/tests/Runtime/JSignPdfRuntimeServiceTest.php new file mode 100644 index 0000000..98e7fe5 --- /dev/null +++ b/tests/Runtime/JSignPdfRuntimeServiceTest.php @@ -0,0 +1,272 @@ +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->setJSignPdfPath($this->testTmpDir); + $jsignParam->setJSignPdfDownloadUrl(''); + $this->assertEquals($this->testTmpDir, $service->getPath($jsignParam)); + } + + public function testGetPathWithoutFatJarButWithLibDirectory(): void + { + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + mkdir($this->testTmpDir . '/lib'); + $jsignParam->setJSignPdfPath($this->testTmpDir); + $jsignParam->setJSignPdfDownloadUrl(''); + $this->assertEquals($this->testTmpDir, $service->getPath($jsignParam)); + } + + public function testGetPathWhenNothingIsInstalled(): void + { + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + $jsignParam->setJSignPdfPath($this->testTmpDir); + $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->setJSignPdfPath(''); + $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->setJSignPdfPath($this->testTmpDir . '/install'); + $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->setJSignPdfPath($this->testTmpDir . '/install'); + $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 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->setJSignPdfPath($this->testTmpDir . '/install'); + $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->setJSignPdfPath($this->testTmpDir . '/install'); + $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->setJSignPdfPath($this->testTmpDir . '/install'); + $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->setJSignPdfPath($this->testTmpDir . '/install'); + $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(); + $service = new JSignPdfRuntimeService(); + + mkdir($this->testTmpDir . '/lib'); + touch($this->testTmpDir . '/.jsignpdf_version_jsignpdf-3.1.0-minimal.zip'); + $jsignParam->setJSignPdfPath($this->testTmpDir); + $jsignParam->setJSignPdfDownloadUrl('https://fake.url/jsignpdf-3.1.0-minimal.zip'); + + $this->assertEquals($this->testTmpDir, $service->getPath($jsignParam)); + } + + public function testDownloadWithInvalidUrl(): void + { + $jsignParam = new JSignParam(); + $service = new JSignPdfRuntimeService(); + $jsignParam->setJSignPdfPath($this->testTmpDir . '/install'); + $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->setJSignPdfPath($this->testTmpDir . '/install'); + $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()); + } + } + } +}