From 5297be69962f4f4bfb93a9ebe4971ce85277c031 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Thu, 27 Aug 2026 10:59:23 -0400 Subject: [PATCH 1/7] docs: add AGENTS.md Signed-off-by: YvesCesar --- AGENTS.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 70 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2fbc37f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# AGENTS.md + +This file provides guidance to coding agents (Claude Code, Codex, etc.) when working with code in this repository. + +## 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. + +Package name is `jsignpdf/jsignpdf-php`, but the PSR-4 namespace is `Jeidison\JSignPDF\` (`src/`) and `Jeidison\JSignPDF\Tests\` (`tests/`). + +## Commands + +Dev tooling lives in isolated `vendor-bin/*` directories managed by `bamarni/composer-bin-plugin`; `composer install` installs them all and creates bin-links in `vendor/bin`. + +```bash +composer install # deps + all vendor-bin tools +composer run test:unit # PHPUnit (fails on warning/risky) +composer run test:coverage # with xdebug coverage +composer run cs:check # php-cs-fixer dry-run (CI lint) +composer run cs:fix # apply formatting +composer run psalm # static analysis, errorLevel 8 +composer run psalm:update-baseline + +# single test / single method +vendor/bin/phpunit --filter testSignSuccess +vendor/bin/phpunit tests/Runtime/JavaRuntimeServiceTest.php +``` + +`example/index.php` is a runnable end-to-end smoke test (generates a self-signed cert, signs `tests/resources/pdf-test.pdf`, writes to `tmp/`). It needs a real Java + jar, so it actually downloads them on first run. + +CI (on PRs only) runs php-cs-fixer, psalm, and PHPUnit on PHP 8.1–8.4. 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. + +## Architecture + +Flow of a `sign()` call: + +1. `JSignPDF` (`src/JSignPDF.php`) — public facade, holds a `JSignParam` and delegates to `JSignService`. +2. `JSignParam` (`src/Sign/JSignParam.php`) — fluent setter/getter value object holding *file contents* (not paths) for the PDF and the `.pfx`, plus every path/URL knob. Its constructor generates a random `tempName` and defaults `tempPath` to `src/../tmp/`, with `javaPath` and `jSignPdfJarPath` derived from it. It also carries the default download URLs (Temurin JRE 21 tarball, JSignPdf 2.3.0 zip). +3. `JSignService` (`src/Sign/JSignService.php`) — the core. Validates params, resolves the Java and jar paths through the runtime services, writes the PDF and certificate to temp files, builds and `exec()`s the CLI command, then reads back `_signed.pdf` and deletes all temp files (also on failure, in the `catch`). +4. `JSignFileService` (`src/JSignFileService.php`) — read/write/delete of the temp files. + +Success is detected by string-matching `"Finished: Signature succesfully created."` in the command output (note the typo — it comes from JSignPdf itself); anything else becomes an `Exception` carrying the raw output. + +### Runtime resolution (`src/Runtime/`) + +Both `JavaRuntimeService` and `JSignPdfRuntimeService` follow the same `getPath(JSignParam)` contract and the same precedence: + +- Java: `isUseJavaInstalled` → literal `java`; else a `javaPath` with no download URL → used as-is; else path + URL → download and extract on demand; else throw. +- Jar: an existing `jSignPdfJarPath` with no download URL → used as-is; else path + URL → download/extract on demand; else throw. + +Downloads are cached by a marker file next to the binary (`.java_version_` / `.jsignpdf_version_`), so changing the download URL invalidates the cache and re-downloads. Extraction uses `PharData` for the `.tar.gz` and `ZipArchive` for the zip, then shells out to `mv`/`rm` to flatten the archive's root directory. + +### Certificate handling quirks + +`JSignService::pkcs12Read()` has two workarounds that are easy to break: + +- **Legacy PKCS#12 / OpenSSL 3**: when `openssl_pkcs12_read` fails with `error:0308010C:digital envelope routines::unsupported`, the cert is repacked via the `openssl pkcs12 -legacy` CLI (password passed through stdin, never argv) and the repacked content is written back into the `JSignParam`. +- **Non-ASCII passwords**: JSignPdf's CLI mishandles them, so the certificate is re-exported in memory under a fresh random password and both the password and certificate on the `JSignParam` are swapped out. + +Because of this, `JSignParam` is mutated during signing — treat it as single-use per sign. + +Everything reaching a shell must go through `escapeshellarg()` (see `commandSign()` and `safeExec()`). + +## Testing notes + +- `tests/JSignPDFTest.php` declares a `Jeidison\JSignPDF\Sign\exec()` function at the top of the file that shadows the global one for that namespace, driven by a `$mockExec` global set per test. Set `$mockExec = ['Finished: Signature succesfully created.']` to simulate a successful sign; `setUp()` resets it to `null`. +- `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 5e017d676bb3e596febb4d4bab8cd774051b0a54 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Fri, 28 Aug 2026 10:44:07 -0400 Subject: [PATCH 2/7] docs: drop redundant CLAUDE.md pointer Signed-off-by: YvesCesar --- CLAUDE.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md From 0d89853892fa4da754db09144e4f6939b6fd761a Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Fri, 28 Aug 2026 10:54:08 -0400 Subject: [PATCH 3/7] docs: limit AGENTS.md to stable operational guidance Signed-off-by: YvesCesar --- AGENTS.md | 43 +++++++++++-------------------------------- 1 file changed, 11 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fbc37f..87b041d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ This file provides guidance to coding agents (Claude Code, Codex, etc.) when working with code in this repository. +## Keeping this file current + +Whenever a change makes any guidance here outdated, update this file in the same pull request. Keep it limited to stable operational guidance — conventions, compatibility requirements, commands, testing patterns and constraints. Implementation details (classes, methods, internal behavior) do not belong here: they go stale on the first refactor, and an agent may mistake a current limitation for a rule to preserve. + ## 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. @@ -10,7 +14,7 @@ Package name is `jsignpdf/jsignpdf-php`, but the PSR-4 namespace is `Jeidison\JS ## Commands -Dev tooling lives in isolated `vendor-bin/*` directories managed by `bamarni/composer-bin-plugin`; `composer install` installs them all and creates bin-links in `vendor/bin`. +Dev tooling lives in isolated `vendor-bin/*` directories managed by `bamarni/composer-bin-plugin`; `composer install` installs them all and creates bin-links in `vendor/bin`. The authoritative list of commands is the `scripts` section of `composer.json`. ```bash composer install # deps + all vendor-bin tools @@ -28,42 +32,17 @@ vendor/bin/phpunit tests/Runtime/JavaRuntimeServiceTest.php `example/index.php` is a runnable end-to-end smoke test (generates a self-signed cert, signs `tests/resources/pdf-test.pdf`, writes to `tmp/`). It needs a real Java + jar, so it actually downloads them on first run. -CI (on PRs only) runs php-cs-fixer, psalm, and PHPUnit on PHP 8.1–8.4. 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. - -## Architecture - -Flow of a `sign()` call: - -1. `JSignPDF` (`src/JSignPDF.php`) — public facade, holds a `JSignParam` and delegates to `JSignService`. -2. `JSignParam` (`src/Sign/JSignParam.php`) — fluent setter/getter value object holding *file contents* (not paths) for the PDF and the `.pfx`, plus every path/URL knob. Its constructor generates a random `tempName` and defaults `tempPath` to `src/../tmp/`, with `javaPath` and `jSignPdfJarPath` derived from it. It also carries the default download URLs (Temurin JRE 21 tarball, JSignPdf 2.3.0 zip). -3. `JSignService` (`src/Sign/JSignService.php`) — the core. Validates params, resolves the Java and jar paths through the runtime services, writes the PDF and certificate to temp files, builds and `exec()`s the CLI command, then reads back `_signed.pdf` and deletes all temp files (also on failure, in the `catch`). -4. `JSignFileService` (`src/JSignFileService.php`) — read/write/delete of the temp files. - -Success is detected by string-matching `"Finished: Signature succesfully created."` in the command output (note the typo — it comes from JSignPdf itself); anything else becomes an `Exception` carrying the raw output. - -### Runtime resolution (`src/Runtime/`) - -Both `JavaRuntimeService` and `JSignPdfRuntimeService` follow the same `getPath(JSignParam)` contract and the same precedence: - -- Java: `isUseJavaInstalled` → literal `java`; else a `javaPath` with no download URL → used as-is; else path + URL → download and extract on demand; else throw. -- Jar: an existing `jSignPdfJarPath` with no download URL → used as-is; else path + URL → download/extract on demand; else throw. - -Downloads are cached by a marker file next to the binary (`.java_version_` / `.jsignpdf_version_`), so changing the download URL invalidates the cache and re-downloads. Extraction uses `PharData` for the `.tar.gz` and `ZipArchive` for the zip, then shells out to `mv`/`rm` to flatten the archive's root directory. - -### Certificate handling quirks - -`JSignService::pkcs12Read()` has two workarounds that are easy to break: +## Compatibility -- **Legacy PKCS#12 / OpenSSL 3**: when `openssl_pkcs12_read` fails with `error:0308010C:digital envelope routines::unsupported`, the cert is repacked via the `openssl pkcs12 -legacy` CLI (password passed through stdin, never argv) and the repacked content is written back into the `JSignParam`. -- **Non-ASCII passwords**: JSignPdf's CLI mishandles them, so the certificate is re-exported in memory under a fresh random password and both the password and certificate on the `JSignParam` are swapped out. +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. -Because of this, `JSignParam` is mutated during signing — treat it as single-use per sign. +## Constraints -Everything reaching a shell must go through `escapeshellarg()` (see `commandSign()` and `safeExec()`). +Everything reaching a shell must go through `escapeshellarg()`. Secrets (certificate passwords in particular) must never be passed through argv — use stdin instead. -## Testing notes +## Testing patterns -- `tests/JSignPDFTest.php` declares a `Jeidison\JSignPDF\Sign\exec()` function at the top of the file that shadows the global one for that namespace, driven by a `$mockExec` global set per test. Set `$mockExec = ['Finished: Signature succesfully created.']` to simulate a successful sign; `setUp()` resets it to `null`. +- 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`). - `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar 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. From aac0dd6212239a359907162f11c7714d8387c679 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 31 Aug 2026 09:08:00 -0400 Subject: [PATCH 4/7] docs: document that tests mirror the src tree Signed-off-by: YvesCesar --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 87b041d..ffe182b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ Everything reaching a shell must go through `escapeshellarg()`. Secrets (certifi ## Testing patterns +- `tests/` mirrors the `src/` tree: `src/Runtime/JavaRuntimeService.php` is covered by `tests/Runtime/JavaRuntimeServiceTest.php`. Keep the same relative path and name test classes `Test`; `tests/Builder/` and `tests/resources/` are support directories outside that 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`). - `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar download endpoints. - `tests/Builder/JSignParamBuilder::withDefault()` returns a `JSignParam` preloaded with `tests/resources/certificado.pfx` (password `123`) and `tests/resources/pdf-test.pdf`. From c7bb3e29e230d8ee97e53300aeb5f30d08d7d632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yves=20C=C3=A9sar=20Amorim=20de=20Azevedo?= <48072419+YvesCesar@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:27:18 -0400 Subject: [PATCH 5/7] Update AGENTS.md Co-authored-by: Vitor Mattos --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ffe182b..b5ac2e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ Everything reaching a shell must go through `escapeshellarg()`. Secrets (certifi ## Testing patterns -- `tests/` mirrors the `src/` tree: `src/Runtime/JavaRuntimeService.php` is covered by `tests/Runtime/JavaRuntimeServiceTest.php`. Keep the same relative path and name test classes `Test`; `tests/Builder/` and `tests/resources/` are support directories outside that mirror. +- `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`). - `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar download endpoints. - `tests/Builder/JSignParamBuilder::withDefault()` returns a `JSignParam` preloaded with `tests/resources/certificado.pfx` (password `123`) and `tests/resources/pdf-test.pdf`. From 84590a3311fb48e355882058960dc8a796b5e2ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yves=20C=C3=A9sar=20Amorim=20de=20Azevedo?= <48072419+YvesCesar@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:27:53 -0400 Subject: [PATCH 6/7] Update AGENTS.md Co-authored-by: Vitor Mattos --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b5ac2e7..b39aee2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,4 +46,4 @@ Everything reaching a shell must go through `escapeshellarg()`. Secrets (certifi - 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`). - `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar 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. +- 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`. From 05515d8c35ed169ea3176c2b786ee13f6700d856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yves=20C=C3=A9sar=20Amorim=20de=20Azevedo?= <48072419+YvesCesar@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:28:05 -0400 Subject: [PATCH 7/7] Update AGENTS.md Co-authored-by: Vitor Mattos --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b39aee2..6bda044 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,6 @@ Everything reaching a shell must go through `escapeshellarg()`. Secrets (certifi - `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`). -- `vfsStream` fakes the filesystem (temp paths, unwritable directories, ownership) and `donatj/mock-webserver` fakes the JRE/jar download endpoints. +- `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`.