Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9589bda
feat: support the JSignPdf 3.x CLI and keep the password out of argv
YvesCesar Sep 1, 2026
819adca
test: add integration tests running the real JSignPdf
YvesCesar Sep 1, 2026
598d1b6
docs: document the JSignPdf 3.x behavior changes
YvesCesar Sep 1, 2026
f3caf97
refactor: rename the ZipArchive variable in JSignPdfRuntimeService
YvesCesar Sep 1, 2026
082e295
fix: replace the previous JSignPdf install instead of merging into it
YvesCesar Sep 1, 2026
00c62d1
fix: prefer the classpath layout over a leftover fat jar
YvesCesar Sep 1, 2026
3ff53d9
test: cover the hash algorithm option and keep coverage off the integ…
YvesCesar Sep 1, 2026
d70261b
ci: run the integration tests in their own job
YvesCesar Sep 1, 2026
3d4413a
docs: state that JSignPdf 2.x is no longer supported
YvesCesar Sep 1, 2026
5410fb6
feat: keep every JSignPdf password out of argv
YvesCesar Sep 2, 2026
e302ad1
refactor: derive the default JSignPdf download URL from a version con…
YvesCesar Sep 3, 2026
b0f76a3
feat: accept an installation directory instead of a fake JSignPdf.jar…
YvesCesar Sep 3, 2026
a1e3a81
feat: drop the string form of setJSignParameters and add addJSignPara…
YvesCesar Sep 3, 2026
b274df0
feat: add setJavaOptions and setEnvironmentVariables to keep javaPath…
YvesCesar Sep 3, 2026
b4da77a
fix: use the JSignPdf exit code instead of matching its success message
YvesCesar Sep 3, 2026
72d80d5
fix: treat a dash coming from the option list as a real password
YvesCesar Sep 4, 2026
f63da98
fix: run getVersion through the same process call used to sign
YvesCesar Sep 4, 2026
8b0348b
feat: key the JSignPdf parameters by option and keep every password o…
YvesCesar Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,43 @@ 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

Comment thread
vitormattos marked this conversation as resolved.
summary:
permissions:
contents: none
runs-on: ubuntu-latest
needs: [changes, phpunit]
needs: [changes, phpunit, integration]

if: always()

name: phpunit-summary

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
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`).

Expand All @@ -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
Expand All @@ -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 an array where an option that takes a value is keyed by the option (`'-kst' => 'PKCS12'`) and a flag is an item without a key, which the package escapes; there is no string form to bypass that. The keys are what tells an option from its value, so a value that looks like a password option (`'--l2-text' => '-tsp'`) is never taken as one. A password option given without its value, and the keystore password (which belongs to `setPassword()`), are rejected with an `InvalidArgumentException` so that no password reaches argv through them. A password cannot contain a line break either, because each one is read as a single line from stdin; the setters and the parameters reject it the same way. `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`.
99 changes: 96 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,9 +71,92 @@ $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']);
```

An option that takes a value is written as `option => value`, and a flag as an
item without a key:

```php
$param->setJSignParameters(['-a', '--overwrite', '-kst' => 'PKCS12']);
```

This is what tells the package which item is an option and which one is its
value, so a value that looks like an option is never mistaken for one:

```php
$param->setJSignParameters(['--l2-text' => '-tsp']); // -tsp is the text, not an option
```

`setJSignParameters()` replaces the current parameters; 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/<pid>/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',
]);
```

A password option always takes its value with it: `['-tsp']` alone, or the
certificate password as `['-ksp' => '...']` — which belongs to `setPassword()` —
is refused with an `InvalidArgumentException` instead of reaching the command
line.

JSignPdf reads one password per line from stdin, so a password cannot contain a
line break: every setter above, and the parameters, reject one with an
`InvalidArgumentException`. Any other value is supported, spaces, shell
metacharacters and non-ASCII characters included.

## 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
Expand All @@ -84,7 +177,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
Expand Down
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
1 change: 1 addition & 0 deletions example/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
103 changes: 76 additions & 27 deletions src/Runtime/JSignPdfRuntimeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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');
Expand Down
Loading
Loading