Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 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`.
71 changes: 68 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,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/<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']);
```

## 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 +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
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