diff --git a/packages/verapdf/.github/FUNDING.yml b/packages/verapdf/.github/FUNDING.yml new file mode 100644 index 0000000000..0446fa42d2 --- /dev/null +++ b/packages/verapdf/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: [mooxphp] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/packages/verapdf/.gitignore b/packages/verapdf/.gitignore new file mode 100644 index 0000000000..f397794a6a --- /dev/null +++ b/packages/verapdf/.gitignore @@ -0,0 +1,50 @@ +# Environment +.env +.env.backup + +# Composer +/vendor +composer.lock +auth.json + +# NPM / Node +/node_modules +npm-debug.log +package-lock.json + +# Laravel +/public/hot +/public/storage +/storage/*.key + +# PHPUnit +.phpunit.result.cache +phpunit.xml + +# Yarn +yarn-error.log + +# PHPStan +/build +phpstan.neon + +# Testbench +testbench.yaml +/workbench/* + +# PHP CS Fixer +.php-cs-fixer.cache + +# Homestead +Homestead.json +Homestead.yaml + +# IDEs +/.idea +/.vscode + +# MacOS +.DS_Store + +# Windows +Thumbs.db diff --git a/packages/verapdf/CHANGELOG.md b/packages/verapdf/CHANGELOG.md new file mode 100644 index 0000000000..243c985005 --- /dev/null +++ b/packages/verapdf/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to `moox/verapdf` will be documented in this file. + +## Unreleased + +- Initial package: veraPDF install/validate/doctor, `VeraPdfService`, `VeraPdfResult`, audit model + morph pivot, Pest fixture tests. diff --git a/packages/verapdf/LICENSE.md b/packages/verapdf/LICENSE.md new file mode 100644 index 0000000000..7dfc5ad0bc --- /dev/null +++ b/packages/verapdf/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Moox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/verapdf/README.md b/packages/verapdf/README.md new file mode 100644 index 0000000000..7fb7661259 --- /dev/null +++ b/packages/verapdf/README.md @@ -0,0 +1,184 @@ +![Moox VeraPdf](https://github.com/mooxphp/moox/raw/main/art/banner/record.jpg) + +# Moox VeraPdf + +veraPDF CLI wrapper for PDF/A-3 validation. The package headless-installs the official greenfield veraPDF distribution, runs the launcher against a PDF, persists audit rows on `verapdf_validations`, and optionally links validations to domain owners via `verapdf_validatables`. + +## Features + +- veraPDF greenfield install (`verapdf:install`, `--force`) via IzPack headless auto-install (CLI pack only; no GUI) +- Health checks (`verapdf:doctor`) and CLI validation (`verapdf:validate`) +- Programmatic validation via `VeraPdfService` and structured `VeraPdfResult` (MRR XML parsing) +- `RecordVeraPdfValidation` action for audit persistence +- Morph pivot `verapdf_validatables` (UUID owners) for optional multi-owner linking +- Moox-standard layout: config, two stub migrations, `resources/lang` (EN/DE) + +## Responsibility Boundaries + +- `moox/verapdf` owns veraPDF installation, PDF/A-3 validation, report output paths, and validation audit persistence. +- Owner packages are external; register allowed types under `verapdf.relations.verapdf_validatables.owner_types` when wiring morph history. +- This package stays generic — no e-billing or host-app knowledge. + +## Requirements + +| Requirement | Purpose | +|-------------|---------| +| `moox/core` | Base model, Moox installer, morph pivot registry | +| Java runtime | Headless IzPack install and veraPDF launcher (`VERAPDF_JAVA_BINARY`, default `java`) | + +### Server prerequisites (headless) + +Deploy on a server with a **JRE only** — no desktop/X11 packages required. + +| Prerequisite | Notes | +|--------------|-------| +| Java **8, 11, 17, or 21** | Prefer a headless JRE on Ubuntu: `sudo apt install default-jre-headless` (or Temurin 17 JRE) | +| No GUI/X11 | `verapdf:install` runs IzPack automated mode with `-Djava.awt.headless=true` and selects the **veraPDF CLI** pack only (GUI/documentation/plugins excluded) | + +```bash +# Headless install (CLI pack only) +php artisan verapdf:install + +# Confirm Java + CLI launcher + bin/*cli*.jar +php artisan verapdf:doctor + +# Smoke-test PDF/A-3 validation +php artisan verapdf:validate /absolute/path/to/file.pdf +``` + +The auto-install stub matches the pinned greenfield installer **1.30.1+** pack list (`veraPDF CLI` / `veraPDF GUI`). Do not point `VERAPDF_DOWNLOAD_URL` at a pre-1.30 zip without updating `resources/install/auto-install.xml.stub`. + +## Installation + +```bash +composer require moox/verapdf +php artisan moox:install +``` + +Install veraPDF artefacts (CLI pack only; safe on headless servers): + +```bash +php artisan verapdf:install +``` + +### Installer integrity + +`verapdf:install` verifies the downloaded greenfield zip against a **pinned SHA-256** (`verapdf.installer.sha256` / `VERAPDF_INSTALLER_SHA256`) before extraction, and rejects archive entries that resolve outside the extract directory (zip-slip). A mismatch or unsafe entry aborts with a clear error and does **not** wipe an existing install (even with `--force`). + +When bumping the pinned veraPDF version, update `installer.version`, `installer.download_url`, and `installer.sha256` together. How to recompute the digest is documented in [SECURITY.md](SECURITY.md). + +Verify Java, CLI launcher, CLI jar, and report directory writability: + +```bash +php artisan verapdf:doctor +``` + +Optionally publish configuration and migrations: + +```bash +php artisan vendor:publish --tag=verapdf-config --force +php artisan vendor:publish --tag=verapdf-migrations --force +php artisan config:clear +``` + +## Usage + +### Environment variables + +| Variable | Config key | Purpose | +|----------|------------|---------| +| `VERAPDF_BASE_PATH` | `base_path` | Root for veraPDF install (default `storage/app/private/verapdf`) | +| `VERAPDF_VERSION` | `installer.version` | Installer version label | +| `VERAPDF_DOWNLOAD_URL` | `installer.download_url` | Greenfield installer zip URL | +| `VERAPDF_INSTALLER_SHA256` | `installer.sha256` | Pinned SHA-256 of that zip (required) | +| `VERAPDF_JAVA_BINARY` | `java_binary` | Java executable (default `java`) | +| `VERAPDF_FLAVOUR` | `flavour` | PDF/A flavour code (default `3b`) | +| `VERAPDF_OUTPUT_PATH` | `output.path` | Report output directory | + +### CLI validation + +```bash +php artisan verapdf:validate /absolute/path/to/file.pdf +``` + +Requires a prior `verapdf:install`. Always persists via `RecordVeraPdfValidation` and prints the validation ID. + +### Programmatic validation + +```php +use Moox\VeraPdf\Services\VeraPdfService; +use Moox\VeraPdf\Actions\RecordVeraPdfValidation; +use Moox\VeraPdf\Support\VeraPdfOutputPath; + +$reportDir = VeraPdfOutputPath::resolve('2026-07-17'); +$result = app(VeraPdfService::class)->validate('/path/to/file.pdf', $reportDir); +$validation = app(RecordVeraPdfValidation::class)($result); + +if (! app(VeraPdfService::class)->isInstalled()) { + // degrade gracefully — run verapdf:install +} +``` + +## Database schema + +### `verapdf_validations` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | bigint PK | | +| `input_path` | string(1024) nullable | Validated PDF path | +| `passed` | boolean | From report `isCompliant` / exit code | +| `errors` | json nullable | Normalized validation messages | +| `report_xml_path` | string(1024) nullable | | +| `report_html_path` | string(1024) nullable | | +| `validated_at` | timestamp | | +| `created_at`, `updated_at` | timestamps | | +| `scope` | string nullable, indexed | Reserved | + +### `verapdf_validatables` + +| Column | Type | Notes | +|--------|------|-------| +| `id` | bigint PK | | +| `validatable_type`, `validatable_id` | uuid morph | Owner | +| `verapdf_validation_id` | foreignId | Cascade delete | +| `created_at`, `updated_at` | timestamps | | +| `scope` | string nullable, indexed | Reserved | + +## Public API + +| Kind | FQCN | +|------|------| +| Service | `Moox\VeraPdf\Services\VeraPdfService` | +| Result DTO | `Moox\VeraPdf\DTOs\VeraPdfResult` | +| Record action | `Moox\VeraPdf\Actions\RecordVeraPdfValidation` | +| Output path | `Moox\VeraPdf\Support\VeraPdfOutputPath` | +| Relation config | `Moox\VeraPdf\Support\VeraPdfRelationConfig` | + +### Artisan commands + +| Command | Options | Description | +|---------|---------|-------------| +| `verapdf:install` | `--force` | Download and headless-install veraPDF (CLI pack only) | +| `verapdf:validate` | `{path}` | Validate PDF, persist audit row | +| `verapdf:doctor` | — | Check Java, CLI launcher/jar, writable output path | + +## Running tests + +From the package directory: + +```bash +composer test +``` + +Or from the monorepo root: + +```bash +php vendor/bin/pest --configuration=packages/verapdf/phpunit.xml packages/verapdf/tests/Unit packages/verapdf/tests/Feature +``` + +Parsing tests use XML fixtures and do not require a live JVM. + +## License + +The MIT License (MIT). Please see [our license and copyright information](https://github.com/mooxphp/moox/blob/main/LICENSE.md) for more information. diff --git a/packages/verapdf/SECURITY.md b/packages/verapdf/SECURITY.md new file mode 100644 index 0000000000..573bdab26f --- /dev/null +++ b/packages/verapdf/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Supported Versions + +We maintain the current version of `moox/verapdf` actively. + +Do not expect security fixes for older versions. + +## Reporting a Vulnerability + +If you find any security-related bug, please report it to security@moox.org. + +Please do not use GitHub issues, to give us enough time to review and fix the issue before others can exploit it. + +## Installer supply chain (`verapdf:install`) + +The greenfield installer zip is treated as untrusted until verified: + +1. **Pinned SHA-256** — `config/verapdf.php` key `installer.sha256` (env: `VERAPDF_INSTALLER_SHA256`) must match the zip bytes at `installer.download_url`. Verification uses `hash_file('sha256')` with `hash_equals`. An empty or malformed pin fails closed. veraPDF also publishes GPG `.asc` signatures on [software.verapdf.org](https://software.verapdf.org/releases/1.30); this package pins the digest in config as the operational integrity check for headless CI/servers without requiring GPG on every host. +2. **Zip-slip rejection** — every ZIP entry is validated before extract; absolute paths and `..` segments are refused (same idea as `VeraPdfOutputPath` subdirectory hardening). +3. **Non-destructive failures** — download and extract run in a system temp staging directory. Checksum or zip-slip failure aborts before IzPack runs and before `--force` deletes an existing `base_path` install. + +### Updating the pin when the veraPDF version changes + +On a trusted machine: + +```bash +URL='https://software.verapdf.org/releases/1.30/verapdf-greenfield-1.30.1-installer.zip' +curl -fsSL -o /tmp/verapdf-installer.zip "$URL" +shasum -a 256 /tmp/verapdf-installer.zip +# or: openssl dgst -sha256 /tmp/verapdf-installer.zip +``` + +Paste the 64-character hex into `installer.sha256` in the same change that updates `installer.version` and `installer.download_url`. If you override `VERAPDF_DOWNLOAD_URL`, you must also set a matching `VERAPDF_INSTALLER_SHA256`. diff --git a/packages/verapdf/banner.jpg b/packages/verapdf/banner.jpg new file mode 100644 index 0000000000..11dca76c01 Binary files /dev/null and b/packages/verapdf/banner.jpg differ diff --git a/packages/verapdf/banner_build.jpg b/packages/verapdf/banner_build.jpg new file mode 100644 index 0000000000..d889a2b55d Binary files /dev/null and b/packages/verapdf/banner_build.jpg differ diff --git a/packages/verapdf/composer.json b/packages/verapdf/composer.json new file mode 100644 index 0000000000..294a0f75c0 --- /dev/null +++ b/packages/verapdf/composer.json @@ -0,0 +1,65 @@ +{ + "name": "moox/verapdf", + "description": "veraPDF CLI wrapper for PDF/A-3 validation with audit persistence.", + "keywords": [ + "Moox", + "Laravel", + "Filament", + "Moox package", + "Laravel package", + "veraPDF", + "PDF/A" + ], + "homepage": "https://moox.org/docs/verapdf", + "license": "MIT", + "authors": [ + { + "name": "Moox Developer", + "email": "dev@moox.org", + "role": "Developer" + } + ], + "require": { + "moox/core": "dev-main" + }, + "autoload": { + "psr-4": { + "Moox\\VeraPdf\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Moox\\VeraPdf\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Moox\\VeraPdf\\VeraPdfServiceProvider" + ] + }, + "moox": { + "stability": "dev" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "moox/devtools": "dev-main", + "pestphp/pest": "^4.7", + "orchestra/testbench": "^10.0|^11.0" + }, + "scripts": { + "test": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml tests/Unit tests/Feature" + ], + "test:arch": [ + "@php ../../vendor/bin/pest --configuration=phpunit.xml tests/ArchTest.php" + ] + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/packages/verapdf/config/verapdf.php b/packages/verapdf/config/verapdf.php new file mode 100644 index 0000000000..7e6f362b8f --- /dev/null +++ b/packages/verapdf/config/verapdf.php @@ -0,0 +1,106 @@ + env('VERAPDF_BASE_PATH', storage_path('app/private/verapdf')), + + /* + | Installer + | + | Greenfield veraPDF installer zip version, download URL, and pinned SHA-256. + | Pin 1.30.1+ — that release splits CLI/GUI packs; auto-install selects CLI only. + | Update sha256 whenever version or download_url changes (see SECURITY.md). + | + */ + 'installer' => [ + 'version' => env('VERAPDF_VERSION', '1.30.1'), + 'download_url' => env( + 'VERAPDF_DOWNLOAD_URL', + 'https://software.verapdf.org/releases/1.30/verapdf-greenfield-1.30.1-installer.zip' + ), + 'sha256' => env( + 'VERAPDF_INSTALLER_SHA256', + '9f03fc5da454348329f4054256351aa6c6a91683329978e8294f21fe8a5d7abc' + ), + ], + + /* + | Paths + | + | Relative launcher name under the base path. On Windows the service + | appends `.bat` when needed. + | + */ + 'paths' => [ + 'launcher' => 'verapdf', + ], + + /* + | Java Binary + | + | Executable used for headless IzPack install and by the veraPDF launcher. + | + */ + 'java_binary' => env('VERAPDF_JAVA_BINARY', 'java'), + + /* + | PDF/A Flavour + | + | Built-in veraPDF profile code. Default is PDF/A-3b (3b). + | Other PDF/A-3 options: 3a, 3u. + | + */ + 'flavour' => env('VERAPDF_FLAVOUR', '3b'), + + /* + | Validation report output + | + | Directory where VeraPdfService writes `{inputBasename}-report.xml` + | (and optional HTML). Override in `.env`: + | + | VERAPDF_OUTPUT_PATH=/var/verapdf/reports + | + */ + 'output' => [ + 'path' => env('VERAPDF_OUTPUT_PATH', storage_path('app/private/verapdf-reports')), + ], + + /* + | Relations (registry) + | + | Declarative list of notable Eloquent relations for this entity. + | Register owner_types when domain models are wired. + | + */ + 'relations' => [ + 'verapdf_validatables' => [ + 'label' => 'trans//verapdf::fields.validatables', + 'relationship' => 'veraPdfValidatables', + 'pivot_model' => VeraPdfValidatable::class, + 'pivot_table' => 'verapdf_validatables', + 'morph_name' => 'validatable', + 'pivot_columns' => [], + 'owner_types' => [ + // Register owner model FQCNs here when wiring morph history. + ], + ], + ], + +]; diff --git a/packages/verapdf/database/migrations/create_verapdf_validatables_table.php.stub b/packages/verapdf/database/migrations/create_verapdf_validatables_table.php.stub new file mode 100644 index 0000000000..e38844ce51 --- /dev/null +++ b/packages/verapdf/database/migrations/create_verapdf_validatables_table.php.stub @@ -0,0 +1,31 @@ +id(); + $table->uuidMorphs('validatable'); + $table->foreignId('verapdf_validation_id')->constrained('verapdf_validations')->cascadeOnDelete(); + $table->timestamps(); + $table->string('scope')->nullable()->index(); + + $table->unique( + ['validatable_type', 'validatable_id', 'verapdf_validation_id'], + 'verapdf_validatables_morph_validation_unique', + ); + }); + } + + public function down(): void + { + Schema::dropIfExists('verapdf_validatables'); + } +}; diff --git a/packages/verapdf/database/migrations/create_verapdf_validations_table.php.stub b/packages/verapdf/database/migrations/create_verapdf_validations_table.php.stub new file mode 100644 index 0000000000..6a948bbea6 --- /dev/null +++ b/packages/verapdf/database/migrations/create_verapdf_validations_table.php.stub @@ -0,0 +1,33 @@ +id(); + $table->string('input_path', 1024)->nullable(); + $table->boolean('passed'); + $table->json('errors')->nullable(); + $table->string('report_xml_path', 1024)->nullable(); + $table->string('report_html_path', 1024)->nullable(); + $table->timestamp('validated_at'); + $table->timestamps(); + $table->string('scope')->nullable()->index(); + + $table->index('passed'); + $table->index('validated_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('verapdf_validations'); + } +}; diff --git a/packages/verapdf/resources/install/auto-install.xml.stub b/packages/verapdf/resources/install/auto-install.xml.stub new file mode 100644 index 0000000000..b63e3e2474 --- /dev/null +++ b/packages/verapdf/resources/install/auto-install.xml.stub @@ -0,0 +1,16 @@ + + + + + + {{INSTALL_PATH}} + + + + + + + + + + diff --git a/packages/verapdf/resources/lang/de/fields.php b/packages/verapdf/resources/lang/de/fields.php new file mode 100644 index 0000000000..c27e7a3864 --- /dev/null +++ b/packages/verapdf/resources/lang/de/fields.php @@ -0,0 +1,29 @@ + 'Dateiname', + 'validation_passed' => 'Prüfung bestanden', + 'validated_at' => 'Geprüft am', + 'result' => 'Ergebnis', + 'errors' => 'Fehler', + 'source_file' => 'Quelldatei', + 'report_html' => 'veraPDF-Bericht HTML', + 'report_xml' => 'veraPDF-Bericht XML', + 'passed' => 'Bestanden', + 'summary' => 'Zusammenfassung', + 'validation_messages' => 'Prüfmeldungen', + 'validation_report' => 'veraPDF-Prüfbericht', + 'no_validation_messages' => 'Keine Prüfmeldungen.', + 'no_report_available' => 'Kein veraPDF-Prüfbericht für diese Prüfung verfügbar.', + 'rule' => 'Regel', + 'location' => 'Position', + 'result_passed' => '✓ Bestanden', + 'result_failed' => '✗ Fehlgeschlagen', + 'severity_error' => 'Fehler', + 'validatables' => 'Zuordnungen', + 'owner' => 'Besitzer', + 'owner_name' => 'Name', + 'add_validatable' => 'Zuordnung hinzufügen', + 'filename_empty' => '—', + 'could_not_parse_report_xml' => 'Prüfbericht-XML konnte nicht gelesen werden', +]; diff --git a/packages/verapdf/resources/lang/de/verapdf.php b/packages/verapdf/resources/lang/de/verapdf.php new file mode 100644 index 0000000000..469dcb2047 --- /dev/null +++ b/packages/verapdf/resources/lang/de/verapdf.php @@ -0,0 +1,6 @@ + 'veraPDF-Prüfung', + 'verapdf-validations' => 'veraPDF-Prüfungen', +]; diff --git a/packages/verapdf/resources/lang/en/fields.php b/packages/verapdf/resources/lang/en/fields.php new file mode 100644 index 0000000000..d89bdcaef3 --- /dev/null +++ b/packages/verapdf/resources/lang/en/fields.php @@ -0,0 +1,29 @@ + 'Filename', + 'validation_passed' => 'Validation passed', + 'validated_at' => 'Validated at', + 'result' => 'Result', + 'errors' => 'Errors', + 'source_file' => 'Source File', + 'report_html' => 'veraPDF Report HTML', + 'report_xml' => 'veraPDF Report XML', + 'passed' => 'Passed', + 'summary' => 'Summary', + 'validation_messages' => 'Validation messages', + 'validation_report' => 'veraPDF validation report', + 'no_validation_messages' => 'No validation messages.', + 'no_report_available' => 'No veraPDF report available for this validation.', + 'rule' => 'Rule', + 'location' => 'Location', + 'result_passed' => '✓ Passed', + 'result_failed' => '✗ Failed', + 'severity_error' => 'Error', + 'validatables' => 'Assignments', + 'owner' => 'Owner', + 'owner_name' => 'Name', + 'add_validatable' => 'Add assignment', + 'filename_empty' => '—', + 'could_not_parse_report_xml' => 'Could not parse report XML', +]; diff --git a/packages/verapdf/resources/lang/en/verapdf.php b/packages/verapdf/resources/lang/en/verapdf.php new file mode 100644 index 0000000000..bdfcde19d6 --- /dev/null +++ b/packages/verapdf/resources/lang/en/verapdf.php @@ -0,0 +1,6 @@ + 'veraPDF Validation', + 'verapdf-validations' => 'veraPDF Validations', +]; diff --git a/packages/verapdf/screenshot/main.jpg b/packages/verapdf/screenshot/main.jpg new file mode 100644 index 0000000000..84670da7fc Binary files /dev/null and b/packages/verapdf/screenshot/main.jpg differ diff --git a/packages/verapdf/screenshot/main_build.jpg b/packages/verapdf/screenshot/main_build.jpg new file mode 100644 index 0000000000..5cc17612db Binary files /dev/null and b/packages/verapdf/screenshot/main_build.jpg differ diff --git a/packages/verapdf/src/Actions/RecordVeraPdfValidation.php b/packages/verapdf/src/Actions/RecordVeraPdfValidation.php new file mode 100644 index 0000000000..017a1bdd5c --- /dev/null +++ b/packages/verapdf/src/Actions/RecordVeraPdfValidation.php @@ -0,0 +1,23 @@ + $result->pdfPath, + 'report_xml_path' => $result->reportXmlPath, + 'report_html_path' => $result->reportHtmlPath, + 'passed' => $result->passed(), + 'errors' => $result->validationMessages(), + 'validated_at' => now(), + ]); + } +} diff --git a/packages/verapdf/src/Commands/DoctorCommand.php b/packages/verapdf/src/Commands/DoctorCommand.php new file mode 100644 index 0000000000..b240016366 --- /dev/null +++ b/packages/verapdf/src/Commands/DoctorCommand.php @@ -0,0 +1,76 @@ +javaAvailable()) { + $this->components->info('Java: OK'); + } else { + $this->components->error('Java: NOT FOUND'); + $allGood = false; + } + + try { + $launcher = $veraPdf->launcherPath(); + $this->components->info("Launcher: {$launcher}"); + } catch (RuntimeException $e) { + $this->components->error('Launcher: '.$e->getMessage()); + $allGood = false; + } + + if ($veraPdf->isInstalled()) { + $this->components->info('Installed: yes'); + } else { + $this->components->error('Installed: no'); + $allGood = false; + } + + if ($veraPdf->hasCliBinaries()) { + $this->components->info('CLI binaries: OK'); + } else { + $this->components->error('CLI binaries: NOT FOUND (expected bin/*cli*.jar from the veraPDF CLI pack)'); + $allGood = false; + } + + if ($veraPdf->hasGuiArtefacts()) { + $this->components->warn('GUI pack artefacts present; slim/headless installs use the CLI pack only.'); + } + + $outputPath = VeraPdfOutputPath::resolve(); + try { + File::ensureDirectoryExists($outputPath); + $this->components->info("Report output: {$outputPath}"); + } catch (\Throwable) { + $this->components->warn("Report output: {$outputPath} (not writable)"); + } + + $this->newLine(); + + if ($allGood) { + $this->components->info('Everything looks good.'); + + return self::SUCCESS; + } + + $this->components->warn('Issues found. Run php artisan verapdf:install to fix.'); + + return self::FAILURE; + } +} diff --git a/packages/verapdf/src/Commands/InstallVeraPdfCommand.php b/packages/verapdf/src/Commands/InstallVeraPdfCommand.php new file mode 100644 index 0000000000..dfceae4274 --- /dev/null +++ b/packages/verapdf/src/Commands/InstallVeraPdfCommand.php @@ -0,0 +1,202 @@ +components->info('Checking Java ...'); + + if (! $veraPdf->javaAvailable()) { + $this->components->error( + 'Java not found. Install a JRE/JDK on the server first (e.g. sudo apt install default-jre-headless).' + ); + + return self::FAILURE; + } + + $this->components->info('Java found.'); + + $basePath = rtrim((string) config('verapdf.base_path'), '/\\'); + + if ($veraPdf->isInstalled() && ! $this->option('force')) { + if ($veraPdf->hasCliBinaries()) { + $this->components->info('veraPDF is already installed. Use --force to reinstall.'); + + return self::SUCCESS; + } + + $this->components->error( + 'veraPDF launcher found but CLI pack is missing. Run php artisan verapdf:install --force to install the CLI pack only.' + ); + + return self::FAILURE; + } + + // Stage download/extract outside base_path so --force wipe happens only after integrity checks. + $stagingDir = rtrim(sys_get_temp_dir(), '/\\').'/verapdf-install-'.uniqid('', true); + + try { + File::ensureDirectoryExists($stagingDir); + + $zipPath = $stagingDir.'/verapdf-installer.zip'; + $versionLabel = 'veraPDF v'.config('verapdf.installer.version'); + $this->downloadFile( + (string) config('verapdf.installer.download_url'), + $zipPath, + $versionLabel + ); + + $this->components->info('Verifying installer checksum ...'); + InstallerChecksum::assertMatches( + $zipPath, + (string) config('verapdf.installer.sha256', '') + ); + + $extractDir = $stagingDir.'/extracted'; + File::ensureDirectoryExists($extractDir); + $this->components->info('Extracting veraPDF installer ...'); + SafeZipExtractor::extract($zipPath, $extractDir); + + if ($this->option('force') && File::exists($basePath)) { + $this->components->warn("Deleting existing installation at {$basePath}"); + File::deleteDirectory($basePath); + } + + File::ensureDirectoryExists($basePath); + + $installerJar = $this->findInstallerJar($extractDir); + $autoInstallXml = $stagingDir.'/auto-install.xml'; + $this->writeAutoInstallXml($autoInstallXml, $basePath); + + $this->components->info('Running headless veraPDF installer ...'); + + $java = (string) config('verapdf.java_binary', 'java'); + $result = Process::timeout(1200)->run([ + $java, + '-Djava.awt.headless=true', + '-jar', $installerJar, + $autoInstallXml, + ]); + + if (! $result->successful() && ! $veraPdf->isInstalled()) { + throw new RuntimeException( + 'veraPDF installer failed: '.trim($result->errorOutput() ?: $result->output()) + ); + } + + $launcher = $basePath.'/'.config('verapdf.paths.launcher', 'verapdf'); + if (is_file($launcher) && PHP_OS_FAMILY !== 'Windows') { + chmod($launcher, 0755); + } + } catch (RuntimeException $e) { + $this->components->error($e->getMessage()); + + return self::FAILURE; + } finally { + if (File::exists($stagingDir)) { + File::deleteDirectory($stagingDir); + } + } + + try { + $launcherPath = $veraPdf->launcherPath(); + } catch (RuntimeException $e) { + $this->components->error('Installation incomplete: '.$e->getMessage()); + + return self::FAILURE; + } + + if (! $veraPdf->hasCliBinaries()) { + $hint = $veraPdf->hasGuiArtefacts() + ? 'GUI pack artefacts found but CLI jar missing — auto-install must select the veraPDF CLI pack (not GUI).' + : 'CLI jar missing under bin/ — auto-install must select the veraPDF CLI pack.'; + $this->components->error('Installation incomplete: '.$hint); + + return self::FAILURE; + } + + $this->newLine(); + $this->components->info('veraPDF CLI installation successful.'); + $this->line(" Launcher: {$launcherPath}"); + $cliJar = $veraPdf->findCliJar(); + if ($cliJar !== null) { + $this->line(" CLI jar: {$cliJar}"); + } + $this->newLine(); + $this->line('Test with: php artisan verapdf:validate /path/to/file.pdf'); + + return self::SUCCESS; + } + + private function downloadFile(string $url, string $target, string $label): void + { + $this->components->info("Downloading {$label} ..."); + + $response = Http::timeout(600) + ->sink($target) + ->withOptions([ + 'allow_redirects' => true, + ]) + ->get($url); + + if (! $response->successful()) { + throw new RuntimeException("Download failed for {$label}: HTTP {$response->status()} from {$url}"); + } + + if (! File::exists($target) || File::size($target) === 0) { + throw new RuntimeException("Download incomplete for {$label}"); + } + } + + private function findInstallerJar(string $extractDir): string + { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($extractDir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if (! $file instanceof SplFileInfo || ! $file->isFile()) { + continue; + } + $name = $file->getFilename(); + if (str_contains($name, 'izpack-installer') && str_ends_with($name, '.jar')) { + return $file->getPathname(); + } + } + + throw new RuntimeException("No veraPDF IzPack installer JAR found under {$extractDir}"); + } + + private function writeAutoInstallXml(string $target, string $installPath): void + { + $stub = dirname(__DIR__, 2).'/resources/install/auto-install.xml.stub'; + + if (! is_file($stub)) { + throw new RuntimeException("Missing auto-install stub at {$stub}"); + } + + $contents = str_replace('{{INSTALL_PATH}}', $installPath, (string) file_get_contents($stub)); + File::put($target, $contents); + } +} diff --git a/packages/verapdf/src/Commands/ValidateCommand.php b/packages/verapdf/src/Commands/ValidateCommand.php new file mode 100644 index 0000000000..e079bb9bce --- /dev/null +++ b/packages/verapdf/src/Commands/ValidateCommand.php @@ -0,0 +1,80 @@ +javaAvailable()) { + $this->components->error( + 'Java not found. Install a JRE/JDK on the server first (e.g. sudo apt install default-jre-headless).' + ); + + return self::FAILURE; + } + + if (! $veraPdf->isInstalled()) { + $this->components->error('veraPDF is not installed. Run php artisan verapdf:install first.'); + + return self::FAILURE; + } + + $path = $this->argument('path'); + + if (! file_exists($path)) { + $this->components->error("File not found: {$path}"); + + return self::FAILURE; + } + + $this->components->info("Validating {$path} ..."); + + try { + $result = $veraPdf->validate($path); + } catch (RuntimeException $e) { + $this->components->error($e->getMessage()); + + return self::FAILURE; + } + + if ($result->passed()) { + $this->components->info('Validation passed.'); + } else { + $this->components->error('Validation failed.'); + $errors = $result->errors(); + + if ($errors !== []) { + $this->newLine(); + $this->components->warn('Errors:'); + foreach ($errors as $i => $error) { + $this->line(' '.($i + 1).'. '.$error); + } + } + } + + if ($result->reportXmlPath) { + $this->line(" Report XML: {$result->reportXmlPath}"); + } + if ($result->reportHtmlPath) { + $this->line(" Report HTML: {$result->reportHtmlPath}"); + } + + $validation = $recordVeraPdfValidation($result); + $this->line(" Validation ID: {$validation->id}"); + + return $result->passed() ? self::SUCCESS : self::FAILURE; + } +} diff --git a/packages/verapdf/src/DTOs/VeraPdfResult.php b/packages/verapdf/src/DTOs/VeraPdfResult.php new file mode 100644 index 0000000000..5beb19bd2f --- /dev/null +++ b/packages/verapdf/src/DTOs/VeraPdfResult.php @@ -0,0 +1,152 @@ +reportXmlPath && file_exists($this->reportXmlPath)) { + $compliant = $this->isCompliantFromReport(); + + if ($compliant !== null) { + return $compliant; + } + } + + return $this->exitCode === 0; + } + + public function failed(): bool + { + return ! $this->passed(); + } + + /** + * Parse the report XML to extract validation messages with severity. + * + * @return list + */ + public function validationMessages(): array + { + if (! $this->reportXmlPath || ! file_exists($this->reportXmlPath)) { + $text = trim($this->stderr ?: $this->stdout); + + return $this->failed() && $text !== '' + ? [['type' => 'error', 'text' => $text, 'location' => null, 'rule' => null]] + : []; + } + + $xml = @simplexml_load_file($this->reportXmlPath); + + if ($xml === false) { + return $this->failed() + ? [['type' => 'error', 'text' => __('verapdf::fields.could_not_parse_report_xml'), 'location' => null, 'rule' => null]] + : []; + } + + $messages = []; + $failedRules = $xml->xpath('//rule[@status="failed"]') ?: []; + + foreach ($failedRules as $rule) { + $description = trim((string) ($rule->description ?? '')); + if ($description === '') { + $description = trim((string) $rule); + } + if ($description === '') { + continue; + } + + $clause = trim((string) ($rule['clause'] ?? '')); + $testNumber = trim((string) ($rule['testNumber'] ?? '')); + $specification = trim((string) ($rule['specification'] ?? '')); + + $ruleId = match (true) { + $clause !== '' && $testNumber !== '' => $clause.'#'.$testNumber, + $clause !== '' => $clause, + $specification !== '' => $specification, + default => null, + }; + + $location = null; + $checks = $rule->xpath('./check[@status="failed"]') ?: []; + if ($checks !== []) { + $context = trim((string) ($checks[0]->context ?? '')); + $location = $context !== '' ? $context : null; + } + + $messages[] = [ + 'type' => 'error', + 'text' => $description, + 'location' => $location, + 'rule' => $ruleId, + ]; + } + + if ($messages === [] && $this->failed()) { + $statement = ''; + $reports = $xml->xpath('//validationReport') ?: []; + if ($reports !== []) { + $statement = trim((string) ($reports[0]['statement'] ?? '')); + } + if ($statement !== '') { + $messages[] = [ + 'type' => 'error', + 'text' => $statement, + 'location' => null, + 'rule' => null, + ]; + } + } + + return $messages; + } + + /** + * @return list + */ + public function errors(): array + { + return array_map( + fn (array $m): string => $m['text'], + array_values(array_filter( + $this->validationMessages(), + fn (array $m): bool => $m['type'] === 'error' + )) + ); + } + + private function isCompliantFromReport(): ?bool + { + $xml = @simplexml_load_file($this->reportXmlPath); + + if ($xml === false) { + return null; + } + + $reports = $xml->xpath('//validationReport') ?: []; + + if ($reports === []) { + return null; + } + + $value = strtolower((string) ($reports[0]['isCompliant'] ?? '')); + + return match ($value) { + 'true' => true, + 'false' => false, + default => null, + }; + } +} diff --git a/packages/verapdf/src/Models/VeraPdfValidatable.php b/packages/verapdf/src/Models/VeraPdfValidatable.php new file mode 100644 index 0000000000..f737e8fe86 --- /dev/null +++ b/packages/verapdf/src/Models/VeraPdfValidatable.php @@ -0,0 +1,39 @@ + + */ + public function validatable(): MorphTo + { + return $this->morphTo(); + } + + /** + * @return BelongsTo + */ + public function veraPdfValidation(): BelongsTo + { + return $this->belongsTo(VeraPdfValidation::class, 'verapdf_validation_id'); + } +} diff --git a/packages/verapdf/src/Models/VeraPdfValidation.php b/packages/verapdf/src/Models/VeraPdfValidation.php new file mode 100644 index 0000000000..fbbda2ef4a --- /dev/null +++ b/packages/verapdf/src/Models/VeraPdfValidation.php @@ -0,0 +1,90 @@ +|null $errors + * @property Carbon|null $validated_at + */ +class VeraPdfValidation extends BaseItemModel +{ + protected $table = 'verapdf_validations'; + + /** + * @var list + */ + protected $fillable = [ + 'input_path', + 'report_xml_path', + 'report_html_path', + 'passed', + 'errors', + 'validated_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'errors' => 'array', + 'passed' => 'boolean', + 'validated_at' => 'datetime', + ]; + } + + public static function getResourceName(): string + { + return 'verapdf-validation'; + } + + /** + * @return HasMany + */ + public function veraPdfValidatables(): HasMany + { + return $this->hasMany(VeraPdfValidatable::class); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopePassed(Builder $query): Builder + { + return $query->where('passed', true); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeFailed(Builder $query): Builder + { + return $query->where('passed', false); + } + + public function filenameLabel(): string + { + return $this->input_path !== null + ? basename($this->input_path) + : __('verapdf::fields.filename_empty'); + } + + public function reportHtmlPath(): ?string + { + return $this->report_html_path; + } +} diff --git a/packages/verapdf/src/Services/VeraPdfService.php b/packages/verapdf/src/Services/VeraPdfService.php new file mode 100644 index 0000000000..f5705ec1d9 --- /dev/null +++ b/packages/verapdf/src/Services/VeraPdfService.php @@ -0,0 +1,207 @@ +javaAvailable()) { + throw new RuntimeException( + 'Java not found. Install a JRE/JDK on the server first (e.g. sudo apt install default-jre-headless).' + ); + } + + if (! $this->isInstalled()) { + throw new RuntimeException( + 'veraPDF is not installed. Run php artisan verapdf:install first.' + ); + } + + if (! file_exists($pdfPath)) { + throw new RuntimeException("File not found: {$pdfPath}"); + } + + $resolvedPdfPath = realpath($pdfPath); + $inputPath = $resolvedPdfPath !== false ? $resolvedPdfPath : $pdfPath; + + $reportDir = $reportDirectory !== null + ? rtrim($reportDirectory, '/\\') + : VeraPdfOutputPath::resolve(); + + File::ensureDirectoryExists($reportDir); + + $flavour = (string) config('verapdf.flavour', '3b'); + $launcher = $this->launcherPath(); + $processEnv = $this->processEnvironment(); + + $result = Process::timeout(600) + ->env($processEnv) + ->run([ + $launcher, + '-f', $flavour, + '--format', 'xml', + $inputPath, + ]); + + $baseName = $this->safeReportBasename($pdfPath); + $reportXml = $reportDir.'/'.$baseName.'-report.xml'; + $reportHtml = $reportDir.'/'.$baseName.'-report.html'; + + $stdout = $result->output(); + if ($stdout !== '') { + File::put($reportXml, $stdout); + } + + $htmlResult = Process::timeout(600) + ->env($processEnv) + ->run([ + $launcher, + '-f', $flavour, + '--format', 'html', + $inputPath, + ]); + + if ($htmlResult->output() !== '') { + File::put($reportHtml, $htmlResult->output()); + } + + return new VeraPdfResult( + exitCode: $result->exitCode() ?? 1, + stdout: $stdout, + stderr: $result->errorOutput(), + reportXmlPath: file_exists($reportXml) ? $reportXml : null, + reportHtmlPath: file_exists($reportHtml) ? $reportHtml : null, + pdfPath: $inputPath, + ); + } + + public function isInstalled(): bool + { + try { + $path = $this->launcherPath(); + + return is_file($path) && (PHP_OS_FAMILY === 'Windows' || is_executable($path)); + } catch (RuntimeException) { + return false; + } + } + + /** + * True when the CLI pack layout is present (launcher + bin/cli-*.jar). + */ + public function hasCliBinaries(): bool + { + if (! $this->isInstalled()) { + return false; + } + + return $this->findCliJar() !== null; + } + + /** + * True when GUI pack artefacts are present (optional warning for slim installs). + */ + public function hasGuiArtefacts(): bool + { + $basePath = rtrim((string) config('verapdf.base_path'), '/\\'); + + if (is_file($basePath.'/verapdf-gui') || is_file($basePath.'/verapdf-gui.bat')) { + return true; + } + + return $this->findJarInBin('gui') !== null; + } + + public function javaAvailable(): bool + { + $java = config('verapdf.java_binary', 'java'); + $result = Process::run([$java, '-version']); + + return $result->successful(); + } + + public function findCliJar(): ?string + { + return $this->findJarInBin('cli'); + } + + private function findJarInBin(string $needle): ?string + { + $binDir = rtrim((string) config('verapdf.base_path'), '/\\').'/bin'; + + if (! is_dir($binDir)) { + return null; + } + + $matches = glob($binDir.'/*'.$needle.'*.jar') ?: []; + + return $matches[0] ?? null; + } + + /** + * Ensure a configured absolute Java binary is preferred by the veraPDF launcher script. + * + * @return array + */ + private function processEnvironment(): array + { + $java = (string) config('verapdf.java_binary', 'java'); + + if ($java === '' || $java === 'java' || (! str_contains($java, '/') && ! str_contains($java, '\\'))) { + return []; + } + + $dir = dirname($java); + $path = $dir.PATH_SEPARATOR.(getenv('PATH') ?: ''); + + return ['PATH' => $path]; + } + + private function safeReportBasename(string $pdfPath): string + { + $baseName = basename(pathinfo($pdfPath, PATHINFO_FILENAME)); + $baseName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $baseName) ?? ''; + $baseName = trim($baseName, '._-'); + + if ($baseName === '' || $baseName === '.' || $baseName === '..') { + return 'verapdf-input'; + } + + return $baseName; + } +} diff --git a/packages/verapdf/src/Support/InstallerChecksum.php b/packages/verapdf/src/Support/InstallerChecksum.php new file mode 100644 index 0000000000..0d2e95257a --- /dev/null +++ b/packages/verapdf/src/Support/InstallerChecksum.php @@ -0,0 +1,45 @@ +open($zipPath) !== true) { + throw new RuntimeException("Cannot open ZIP: {$zipPath}"); + } + + try { + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if (! is_string($name) || $name === '') { + throw new RuntimeException('Refusing to extract unsafe ZIP entry: (empty name)'); + } + + self::assertEntryIsSafe($zip, $i, $name, $targetDir); + } + + if (! $zip->extractTo($targetDir)) { + throw new RuntimeException("Cannot extract ZIP: {$zipPath}"); + } + } finally { + $zip->close(); + } + } + + /** + * Reject absolute paths, parent/current segments, and symlink entries (aligned with VeraPdfOutputPath). + */ + private static function assertEntryIsSafe(ZipArchive $zip, int $index, string $entryName, string $targetDir): void + { + if (self::isSymlinkEntry($zip, $index)) { + throw new RuntimeException("Refusing to extract unsafe ZIP entry: {$entryName}"); + } + + $normalized = str_replace('\\', '/', $entryName); + + if ($normalized === '' || str_starts_with($normalized, '/') || preg_match('#^[A-Za-z]:/#', $normalized) === 1) { + throw new RuntimeException("Refusing to extract unsafe ZIP entry: {$entryName}"); + } + + foreach (explode('/', rtrim($normalized, '/')) as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + throw new RuntimeException("Refusing to extract unsafe ZIP entry: {$entryName}"); + } + } + + $targetRoot = rtrim(str_replace('\\', '/', $targetDir), '/'); + $destination = $targetRoot.'/'.ltrim($normalized, '/'); + + if ($destination !== $targetRoot && ! str_starts_with($destination, $targetRoot.'/')) { + throw new RuntimeException("Refusing to extract unsafe ZIP entry: {$entryName}"); + } + } + + private static function isSymlinkEntry(ZipArchive $zip, int $index): bool + { + $opsys = 0; + $attr = 0; + + if ($zip->getExternalAttributesIndex($index, $opsys, $attr) !== true) { + return false; + } + + if ($opsys !== ZipArchive::OPSYS_UNIX) { + return false; + } + + $mode = ($attr >> 16) & 0o170000; + + return $mode === 0o120000; + } +} diff --git a/packages/verapdf/src/Support/VeraPdfOutputPath.php b/packages/verapdf/src/Support/VeraPdfOutputPath.php new file mode 100644 index 0000000000..2379266133 --- /dev/null +++ b/packages/verapdf/src/Support/VeraPdfOutputPath.php @@ -0,0 +1,50 @@ + + */ + public static function veraPdfValidatables(): array + { + /** @var array $config */ + $config = config('verapdf.relations.verapdf_validatables', []); + + return $config; + } + + public static function relationshipName(): string + { + return (string) (self::veraPdfValidatables()['relationship'] ?? 'veraPdfValidatables'); + } + + public static function pivotTable(): string + { + return (string) (self::veraPdfValidatables()['pivot_table'] ?? 'verapdf_validatables'); + } + + public static function morphName(): string + { + return (string) (self::veraPdfValidatables()['morph_name'] ?? 'validatable'); + } + + /** + * @return list + */ + public static function pivotColumns(): array + { + /** @var list $columns */ + $columns = self::veraPdfValidatables()['pivot_columns'] ?? []; + + return $columns; + } + + /** + * @return array + */ + public static function ownerTypes(): array + { + /** @var array $types */ + $types = self::veraPdfValidatables()['owner_types'] ?? []; + + return $types; + } +} diff --git a/packages/verapdf/src/Tests/TestCase.php b/packages/verapdf/src/Tests/TestCase.php new file mode 100644 index 0000000000..4cbd5cc7be --- /dev/null +++ b/packages/verapdf/src/Tests/TestCase.php @@ -0,0 +1,74 @@ +addPsr4('Moox\\VeraPdf\\Tests\\', dirname(__DIR__, 2).'/tests'); + + return; + } + } +})(); + +if (class_exists(Orchestra::class)) { + #[WithMigration('laravel')] + class TestCase extends Orchestra + { + use RefreshDatabase; + + protected function setUp(): void + { + parent::setUp(); + } + + /** + * @return list + */ + protected function getPackageProviders($app): array + { + return [ + CoreServiceProvider::class, + VeraPdfServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32))); + $app['config']->set('database.default', 'testing'); + $app['config']->set('session.driver', 'array'); + } + + protected function defineDatabaseMigrations(): void + { + $validations = include dirname(__DIR__, 2).'/database/migrations/create_verapdf_validations_table.php.stub'; + $validations->up(); + + $validatables = include dirname(__DIR__, 2).'/database/migrations/create_verapdf_validatables_table.php.stub'; + $validatables->up(); + } + } +} else { + class TestCase extends ApplicationTestCase + { + use RefreshDatabase; + } +} diff --git a/packages/verapdf/src/VeraPdfServiceProvider.php b/packages/verapdf/src/VeraPdfServiceProvider.php new file mode 100644 index 0000000000..d5a4cb7a97 --- /dev/null +++ b/packages/verapdf/src/VeraPdfServiceProvider.php @@ -0,0 +1,58 @@ + ['input_path', 'passed', 'validated_at'], + 'translation_prefix' => 'verapdf::fields', + 'record_select_label' => 'filenameLabel', + 'record_select_search_columns' => ['input_path'], + ]); + } + + public function configureMoox(Package $package): void + { + $package + ->name('verapdf') + ->hasConfigFile() + ->hasTranslations() + ->hasCommands([InstallVeraPdfCommand::class, ValidateCommand::class, DoctorCommand::class]) + ->hasMigrations([ + 'create_verapdf_validations_table', + 'create_verapdf_validatables_table', + ]); + + $this->getMooxPackage() + ->title('Moox VeraPdf') + ->released(false) + ->stability('dev') + ->category('development') + ->usedFor([ + 'veraPDF CLI wrapper for PDF/A-3 validation and audit persistence', + ]); + } + + public function register(): void + { + parent::register(); + + $this->app->singleton(VeraPdfService::class); + } +} diff --git a/packages/verapdf/tests/ArchTest.php b/packages/verapdf/tests/ArchTest.php new file mode 100644 index 0000000000..b66f0186d7 --- /dev/null +++ b/packages/verapdf/tests/ArchTest.php @@ -0,0 +1,8 @@ +expect('Moox\VeraPdf') + ->toUseStrictTypes() + ->not->toUse(['die', 'dd', 'dump']); diff --git a/packages/verapdf/tests/Feature/RecordVeraPdfValidationTest.php b/packages/verapdf/tests/Feature/RecordVeraPdfValidationTest.php new file mode 100644 index 0000000000..6f53544a4b --- /dev/null +++ b/packages/verapdf/tests/Feature/RecordVeraPdfValidationTest.php @@ -0,0 +1,35 @@ +exists)->toBeTrue() + ->and($validation->input_path)->toBe('/abs/path/file.pdf') + ->and($validation->report_xml_path)->toBe('/abs/path/file-report.xml') + ->and($validation->report_html_path)->toBe('/abs/path/file-report.html') + ->and($validation->passed)->toBeTrue() + ->and($validation->errors)->toBe([]); + + $fresh = $validation->fresh(); + expect($fresh->getAttributes())->not->toHaveKey('subject_type') + ->and($fresh->getAttributes())->not->toHaveKey('subject_id') + ->and($fresh->getAttributes())->not->toHaveKey('pdf_path') + ->and($fresh->getAttributes())->not->toHaveKey('report_path'); +}); diff --git a/packages/verapdf/tests/Pest.php b/packages/verapdf/tests/Pest.php new file mode 100644 index 0000000000..1ca59fe819 --- /dev/null +++ b/packages/verapdf/tests/Pest.php @@ -0,0 +1,8 @@ +extends(TestCase::class) + ->in('Feature', 'Unit'); diff --git a/packages/verapdf/tests/TestCase.php b/packages/verapdf/tests/TestCase.php new file mode 120000 index 0000000000..62e29b5501 --- /dev/null +++ b/packages/verapdf/tests/TestCase.php @@ -0,0 +1 @@ +../src/Tests/TestCase.php \ No newline at end of file diff --git a/packages/verapdf/tests/Unit/AutoInstallStubTest.php b/packages/verapdf/tests/Unit/AutoInstallStubTest.php new file mode 100644 index 0000000000..ffbe5543d2 --- /dev/null +++ b/packages/verapdf/tests/Unit/AutoInstallStubTest.php @@ -0,0 +1,17 @@ +toBeTrue(); + + $contents = (string) file_get_contents($stub); + + expect($contents) + ->toContain('name="veraPDF CLI" selected="true"') + ->toContain('name="veraPDF GUI" selected="false"') + ->not->toContain('Mac and *nix Scripts') + ->not->toContain('Validation model'); +}); diff --git a/packages/verapdf/tests/Unit/DoctorCommandTest.php b/packages/verapdf/tests/Unit/DoctorCommandTest.php new file mode 100644 index 0000000000..ef8378f1cb --- /dev/null +++ b/packages/verapdf/tests/Unit/DoctorCommandTest.php @@ -0,0 +1,61 @@ +set('verapdf.base_path', sys_get_temp_dir().'/verapdf-doctor-'.uniqid()); + config()->set('verapdf.paths.launcher', 'verapdf'); + config()->set('verapdf.java_binary', 'java'); + config()->set('verapdf.output.path', sys_get_temp_dir().'/verapdf-reports-'.uniqid()); +}); + +afterEach(function (): void { + foreach (['verapdf.base_path', 'verapdf.output.path'] as $key) { + $path = config($key); + if (is_string($path) && is_dir($path)) { + File::deleteDirectory($path); + } + } +}); + +test('doctor succeeds for a CLI-only install layout', function (): void { + $base = (string) config('verapdf.base_path'); + File::ensureDirectoryExists($base.'/bin'); + file_put_contents($base.'/verapdf', "#!/bin/sh\nexit 0\n"); + chmod($base.'/verapdf', 0755); + file_put_contents($base.'/bin/cli-1.30.1.jar', 'fake'); + + Process::fake(fn () => Process::result( + output: '', + errorOutput: 'openjdk version "17"', + exitCode: 0, + )); + + $this->artisan('verapdf:doctor') + ->expectsOutputToContain('CLI binaries: OK') + ->assertSuccessful(); +}); + +test('doctor fails when only GUI artefacts are present', function (): void { + $base = (string) config('verapdf.base_path'); + File::ensureDirectoryExists($base.'/bin'); + file_put_contents($base.'/verapdf-gui', "#!/bin/sh\nexit 0\n"); + chmod($base.'/verapdf-gui', 0755); + file_put_contents($base.'/bin/gui-1.30.1.jar', 'fake'); + + Process::fake(fn () => Process::result( + output: '', + errorOutput: 'openjdk version "17"', + exitCode: 0, + )); + + $this->artisan('verapdf:doctor') + ->expectsOutputToContain('Launcher:') + ->assertFailed(); +}); diff --git a/packages/verapdf/tests/Unit/InstallVeraPdfCommandTest.php b/packages/verapdf/tests/Unit/InstallVeraPdfCommandTest.php new file mode 100644 index 0000000000..8cbf4de4b3 --- /dev/null +++ b/packages/verapdf/tests/Unit/InstallVeraPdfCommandTest.php @@ -0,0 +1,187 @@ +set('verapdf.base_path', sys_get_temp_dir().'/verapdf-install-'.uniqid()); + config()->set('verapdf.paths.launcher', 'verapdf'); + config()->set('verapdf.java_binary', 'java'); + config()->set('verapdf.installer.version', '1.30.1'); + config()->set( + 'verapdf.installer.download_url', + 'https://software.verapdf.org/releases/1.30/verapdf-greenfield-1.30.1-installer.zip' + ); +}); + +afterEach(function (): void { + $base = config('verapdf.base_path'); + if (is_string($base) && is_dir($base)) { + File::deleteDirectory($base); + } +}); + +test('install succeeds early when CLI layout is already present', function (): void { + $base = (string) config('verapdf.base_path'); + File::ensureDirectoryExists($base.'/bin'); + file_put_contents($base.'/verapdf', "#!/bin/sh\nexit 0\n"); + chmod($base.'/verapdf', 0755); + file_put_contents($base.'/bin/cli-1.30.1.jar', 'fake'); + + Process::fake(fn () => Process::result( + output: '', + errorOutput: 'openjdk version "17"', + exitCode: 0, + )); + + $this->artisan('verapdf:install') + ->expectsOutputToContain('already installed') + ->assertSuccessful(); +}); + +test('install fails when launcher exists without CLI pack', function (): void { + $base = (string) config('verapdf.base_path'); + File::ensureDirectoryExists($base); + file_put_contents($base.'/verapdf', "#!/bin/sh\nexit 0\n"); + chmod($base.'/verapdf', 0755); + + Process::fake(fn () => Process::result( + output: '', + errorOutput: 'openjdk version "17"', + exitCode: 0, + )); + + $this->artisan('verapdf:install') + ->expectsOutputToContain('CLI pack is missing') + ->assertFailed(); +}); + +test('install aborts on checksum mismatch without wiping an existing install', function (): void { + $base = (string) config('verapdf.base_path'); + File::ensureDirectoryExists($base.'/bin'); + file_put_contents($base.'/verapdf', "#!/bin/sh\nexit 0\n"); + chmod($base.'/verapdf', 0755); + file_put_contents($base.'/bin/cli-1.30.1.jar', 'existing-cli'); + + $payload = 'tampered-installer-bytes'; + config()->set('verapdf.installer.sha256', hash('sha256', 'different-expected-bytes')); + + Http::fake([ + '*' => Http::response($payload, 200), + ]); + + $installerJarRan = false; + Process::fake(function (PendingProcess $process) use (&$installerJarRan) { + $command = $process->command; + if (is_array($command) && in_array('-jar', $command, true)) { + $installerJarRan = true; + } + + return Process::result( + output: '', + errorOutput: 'openjdk version "17"', + exitCode: 0, + ); + }); + + $this->artisan('verapdf:install', ['--force' => true]) + ->expectsOutputToContain('checksum mismatch') + ->assertFailed(); + + expect(is_file($base.'/verapdf'))->toBeTrue() + ->and(is_file($base.'/bin/cli-1.30.1.jar'))->toBeTrue() + ->and((string) file_get_contents($base.'/bin/cli-1.30.1.jar'))->toBe('existing-cli') + ->and($installerJarRan)->toBeFalse(); +}); + +test('install aborts on zip-slip archive before running the installer', function (): void { + $zipPath = sys_get_temp_dir().'/verapdf-cmd-slip-'.uniqid('', true).'.zip'; + $zip = new ZipArchive; + expect($zip->open($zipPath, ZipArchive::CREATE))->toBeTrue(); + $zip->addFromString('../evil.txt', 'pwned'); + $zip->close(); + + $bytes = (string) file_get_contents($zipPath); + config()->set('verapdf.installer.sha256', hash('sha256', $bytes)); + + Http::fake([ + '*' => Http::response($bytes, 200), + ]); + + $installerJarRan = false; + Process::fake(function (PendingProcess $process) use (&$installerJarRan) { + $command = $process->command; + if (is_array($command) && in_array('-jar', $command, true)) { + $installerJarRan = true; + } + + return Process::result( + output: '', + errorOutput: 'openjdk version "17"', + exitCode: 0, + ); + }); + + $this->artisan('verapdf:install') + ->expectsOutputToContain('unsafe ZIP entry') + ->assertFailed(); + + $base = (string) config('verapdf.base_path'); + expect(is_file($base.'/verapdf'))->toBeFalse() + ->and($installerJarRan)->toBeFalse(); + + File::delete($zipPath); +}); + +test('install aborts on zip-slip with --force without wiping an existing install', function (): void { + $base = (string) config('verapdf.base_path'); + File::ensureDirectoryExists($base.'/bin'); + file_put_contents($base.'/verapdf', "#!/bin/sh\nexit 0\n"); + chmod($base.'/verapdf', 0755); + file_put_contents($base.'/bin/cli-1.30.1.jar', 'existing-cli'); + + $zipPath = sys_get_temp_dir().'/verapdf-cmd-force-slip-'.uniqid('', true).'.zip'; + $zip = new ZipArchive; + expect($zip->open($zipPath, ZipArchive::CREATE))->toBeTrue(); + $zip->addFromString('../evil.txt', 'pwned'); + $zip->close(); + + $bytes = (string) file_get_contents($zipPath); + config()->set('verapdf.installer.sha256', hash('sha256', $bytes)); + + Http::fake([ + '*' => Http::response($bytes, 200), + ]); + + $installerJarRan = false; + Process::fake(function (PendingProcess $process) use (&$installerJarRan) { + $command = $process->command; + if (is_array($command) && in_array('-jar', $command, true)) { + $installerJarRan = true; + } + + return Process::result( + output: '', + errorOutput: 'openjdk version "17"', + exitCode: 0, + ); + }); + + $this->artisan('verapdf:install', ['--force' => true]) + ->expectsOutputToContain('unsafe ZIP entry') + ->assertFailed(); + + expect(is_file($base.'/verapdf'))->toBeTrue() + ->and(is_file($base.'/bin/cli-1.30.1.jar'))->toBeTrue() + ->and((string) file_get_contents($base.'/bin/cli-1.30.1.jar'))->toBe('existing-cli') + ->and($installerJarRan)->toBeFalse(); + + File::delete($zipPath); +}); diff --git a/packages/verapdf/tests/Unit/InstallerChecksumTest.php b/packages/verapdf/tests/Unit/InstallerChecksumTest.php new file mode 100644 index 0000000000..3be74642cd --- /dev/null +++ b/packages/verapdf/tests/Unit/InstallerChecksumTest.php @@ -0,0 +1,58 @@ + InstallerChecksum::assertMatches($path, $expected)) + ->not->toThrow(Throwable::class); + + File::delete($path); +}); + +it('throws when the checksum does not match', function (): void { + $path = sys_get_temp_dir().'/verapdf-checksum-'.uniqid('', true).'.bin'; + file_put_contents($path, 'tampered'); + + expect(fn () => InstallerChecksum::assertMatches($path, str_repeat('a', 64))) + ->toThrow(RuntimeException::class, 'checksum mismatch'); + + File::delete($path); +}); + +it('throws when the expected checksum is empty', function (): void { + $path = sys_get_temp_dir().'/verapdf-checksum-'.uniqid('', true).'.bin'; + file_put_contents($path, 'bytes'); + + expect(fn () => InstallerChecksum::assertMatches($path, '')) + ->toThrow(RuntimeException::class, 'checksum is not configured'); + + File::delete($path); +}); + +it('throws when the expected checksum is malformed', function (): void { + $path = sys_get_temp_dir().'/verapdf-checksum-'.uniqid('', true).'.bin'; + file_put_contents($path, 'bytes'); + + expect(fn () => InstallerChecksum::assertMatches($path, 'not-a-sha256')) + ->toThrow(RuntimeException::class, 'checksum is not configured'); + + File::delete($path); +}); + +it('throws when the file is missing', function (): void { + $path = sys_get_temp_dir().'/verapdf-checksum-missing-'.uniqid('', true).'.bin'; + + expect(fn () => InstallerChecksum::assertMatches($path, str_repeat('b', 64))) + ->toThrow(RuntimeException::class, 'Cannot verify checksum'); +}); diff --git a/packages/verapdf/tests/Unit/RecordVeraPdfValidationTest.php b/packages/verapdf/tests/Unit/RecordVeraPdfValidationTest.php new file mode 100644 index 0000000000..8c9d285dc5 --- /dev/null +++ b/packages/verapdf/tests/Unit/RecordVeraPdfValidationTest.php @@ -0,0 +1,82 @@ +toBeInstanceOf(VeraPdfValidation::class) + ->and($validation->passed)->toBeTrue() + ->and($validation->input_path)->toBe('/tmp/file.pdf') + ->and($validation->report_xml_path)->toBe('/tmp/report.xml') + ->and($validation->report_html_path)->toBe('/tmp/report.html'); +}); + +it('stores errors as JSON from report fixture', function (): void { + $reportPath = __DIR__.'/../fixtures/verapdf-report-fail.xml'; + + $validation = app(RecordVeraPdfValidation::class)(new VeraPdfResult( + exitCode: 1, + stdout: '', + stderr: '', + reportXmlPath: $reportPath, + reportHtmlPath: null, + pdfPath: '/tmp/file.pdf', + )); + + $validation->refresh(); + + expect($validation->passed)->toBeFalse() + ->and($validation->errors)->toBe([ + [ + 'type' => 'error', + 'text' => 'If an image dictionary contains the Interpolate key, its value shall be false.', + 'location' => 'root/document[0]/pages[0]/contentStream[0]/operators[65]/xObject[0]', + 'rule' => '6.2.11.3#1', + ], + ]); +}); + +it('stores the validated_at timestamp', function (): void { + $before = now()->subSecond(); + + $validation = app(RecordVeraPdfValidation::class)(new VeraPdfResult( + exitCode: 0, + stdout: '', + stderr: '', + reportXmlPath: null, + reportHtmlPath: null, + )); + + $after = now()->addSecond(); + + expect($validation->validated_at)->not->toBeNull() + ->and($validation->validated_at->between($before, $after))->toBeTrue(); +}); + +it('handles a null input path gracefully', function (): void { + $validation = app(RecordVeraPdfValidation::class)(new VeraPdfResult( + exitCode: 0, + stdout: '', + stderr: '', + reportXmlPath: null, + reportHtmlPath: null, + pdfPath: null, + )); + + expect($validation->input_path)->toBeNull(); +}); diff --git a/packages/verapdf/tests/Unit/SafeZipExtractorTest.php b/packages/verapdf/tests/Unit/SafeZipExtractorTest.php new file mode 100644 index 0000000000..a6c4b4f984 --- /dev/null +++ b/packages/verapdf/tests/Unit/SafeZipExtractorTest.php @@ -0,0 +1,110 @@ +open($zipPath, ZipArchive::CREATE))->toBeTrue(); + $zip->addFromString('nested/readme.txt', 'ok'); + $zip->close(); + + SafeZipExtractor::extract($zipPath, $target); + + expect(is_file($target.'/nested/readme.txt'))->toBeTrue() + ->and((string) file_get_contents($target.'/nested/readme.txt'))->toBe('ok'); + + File::delete($zipPath); + File::deleteDirectory($target); +}); + +it('rejects zip entries with parent-path segments', function (): void { + $zipPath = sys_get_temp_dir().'/verapdf-slip-zip-'.uniqid('', true).'.zip'; + $target = sys_get_temp_dir().'/verapdf-slip-out-'.uniqid('', true); + File::ensureDirectoryExists($target); + + $zip = new ZipArchive; + expect($zip->open($zipPath, ZipArchive::CREATE))->toBeTrue(); + $zip->addFromString('../evil.txt', 'pwned'); + $zip->close(); + + expect(fn () => SafeZipExtractor::extract($zipPath, $target)) + ->toThrow(RuntimeException::class, 'unsafe ZIP entry'); + + expect(is_file(dirname($target).'/evil.txt'))->toBeFalse(); + + File::delete($zipPath); + File::deleteDirectory($target); +}); + +it('rejects absolute zip entries', function (): void { + $zipPath = sys_get_temp_dir().'/verapdf-abs-zip-'.uniqid('', true).'.zip'; + $target = sys_get_temp_dir().'/verapdf-abs-out-'.uniqid('', true); + File::ensureDirectoryExists($target); + + $zip = new ZipArchive; + expect($zip->open($zipPath, ZipArchive::CREATE))->toBeTrue(); + $zip->addFromString('/tmp/absolute-evil.txt', 'pwned'); + $zip->close(); + + expect(fn () => SafeZipExtractor::extract($zipPath, $target)) + ->toThrow(RuntimeException::class, 'unsafe ZIP entry'); + + File::delete($zipPath); + File::deleteDirectory($target); +}); + +it('rejects zip entries with current-directory segments', function (): void { + $zipPath = sys_get_temp_dir().'/verapdf-dot-zip-'.uniqid('', true).'.zip'; + $target = sys_get_temp_dir().'/verapdf-dot-out-'.uniqid('', true); + File::ensureDirectoryExists($target); + + $zip = new ZipArchive; + expect($zip->open($zipPath, ZipArchive::CREATE))->toBeTrue(); + $zip->addFromString('foo/./evil.txt', 'pwned'); + $zip->close(); + + expect(fn () => SafeZipExtractor::extract($zipPath, $target)) + ->toThrow(RuntimeException::class, 'unsafe ZIP entry'); + + File::delete($zipPath); + File::deleteDirectory($target); +}); + +it('rejects symlink zip entries', function (): void { + $zipPath = sys_get_temp_dir().'/verapdf-link-zip-'.uniqid('', true).'.zip'; + $target = sys_get_temp_dir().'/verapdf-link-out-'.uniqid('', true); + File::ensureDirectoryExists($target); + + $zip = new ZipArchive; + expect($zip->open($zipPath, ZipArchive::CREATE))->toBeTrue(); + $zip->addFromString('escape-link', '/tmp/outside'); + $zip->setExternalAttributesName('escape-link', ZipArchive::OPSYS_UNIX, (0o120000 | 0o777) << 16); + $zip->close(); + + expect(fn () => SafeZipExtractor::extract($zipPath, $target)) + ->toThrow(RuntimeException::class, 'unsafe ZIP entry'); + + File::delete($zipPath); + File::deleteDirectory($target); +}); + +it('throws when the archive cannot be opened', function (): void { + $path = sys_get_temp_dir().'/verapdf-not-a-zip-'.uniqid('', true).'.zip'; + file_put_contents($path, 'not-a-zip'); + + $target = sys_get_temp_dir().'/verapdf-not-zip-out-'.uniqid('', true); + + expect(fn () => SafeZipExtractor::extract($path, $target)) + ->toThrow(RuntimeException::class, 'Cannot open ZIP'); + + File::delete($path); +}); diff --git a/packages/verapdf/tests/Unit/VeraPdfOutputPathTest.php b/packages/verapdf/tests/Unit/VeraPdfOutputPathTest.php new file mode 100644 index 0000000000..2c54d40c56 --- /dev/null +++ b/packages/verapdf/tests/Unit/VeraPdfOutputPathTest.php @@ -0,0 +1,36 @@ + $tempBase]); + + expect(VeraPdfOutputPath::resolve())->toBe($tempBase) + ->and(is_dir($tempBase))->toBeTrue(); + + File::deleteDirectory($tempBase); +}); + +it('appends an optional subdirectory segment', function (): void { + $tempBase = sys_get_temp_dir().'/verapdf-out-'.uniqid('', true); + config(['verapdf.output.path' => $tempBase]); + + expect(VeraPdfOutputPath::resolve('2026/07/17')) + ->toBe($tempBase.'/2026/07/17'); + + File::deleteDirectory($tempBase); +}); + +it('rejects parent-path segments in subdirectory', function (): void { + config(['verapdf.output.path' => '/tmp/verapdf-reports']); + + expect(fn () => VeraPdfOutputPath::resolve('../escape')) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/packages/verapdf/tests/Unit/VeraPdfResultTest.php b/packages/verapdf/tests/Unit/VeraPdfResultTest.php new file mode 100644 index 0000000000..70358b6464 --- /dev/null +++ b/packages/verapdf/tests/Unit/VeraPdfResultTest.php @@ -0,0 +1,55 @@ +passed())->toBeTrue()->and($ok->failed())->toBeFalse(); + + $bad = new VeraPdfResult(1, '', '', null, null); + expect($bad->passed())->toBeFalse()->and($bad->failed())->toBeTrue(); +}); + +test('errors falls back to stderr when no report and failed', function (): void { + $result = new VeraPdfResult(1, '', 'Something broke', null, null); + expect($result->errors())->toBe(['Something broke']); +}); + +test('errors is empty when passed without report', function (): void { + $result = new VeraPdfResult(0, '', '', null, null); + expect($result->errors())->toBe([]); +}); + +test('errors prefers stdout when stderr empty and failed', function (): void { + $result = new VeraPdfResult(1, 'stdout msg', '', null, null); + expect($result->errors())->toBe(['stdout msg']); +}); + +test('passed reads isCompliant from veraPDF report fixture', function (): void { + $passPath = __DIR__.'/../fixtures/verapdf-report-pass.xml'; + $failPath = __DIR__.'/../fixtures/verapdf-report-fail.xml'; + + $pass = new VeraPdfResult(1, '', '', $passPath, null); + expect($pass->passed())->toBeTrue()->and($pass->errors())->toBe([]); + + $fail = new VeraPdfResult(0, '', '', $failPath, null); + expect($fail->passed())->toBeFalse(); +}); + +test('validationMessages parses failed rules from veraPDF report', function (): void { + $path = __DIR__.'/../fixtures/verapdf-report-fail.xml'; + $result = new VeraPdfResult(1, '', '', $path, null); + + expect($result->validationMessages())->toBe([ + [ + 'type' => 'error', + 'text' => 'If an image dictionary contains the Interpolate key, its value shall be false.', + 'location' => 'root/document[0]/pages[0]/contentStream[0]/operators[65]/xObject[0]', + 'rule' => '6.2.11.3#1', + ], + ])->and($result->errors())->toBe([ + 'If an image dictionary contains the Interpolate key, its value shall be false.', + ]); +}); diff --git a/packages/verapdf/tests/Unit/VeraPdfServiceTest.php b/packages/verapdf/tests/Unit/VeraPdfServiceTest.php new file mode 100644 index 0000000000..9485a4715d --- /dev/null +++ b/packages/verapdf/tests/Unit/VeraPdfServiceTest.php @@ -0,0 +1,100 @@ +set('verapdf.base_path', sys_get_temp_dir().'/verapdf-test-'.uniqid()); + config()->set('verapdf.paths.launcher', 'verapdf'); + config()->set('verapdf.java_binary', 'java'); +}); + +afterEach(function (): void { + $base = config('verapdf.base_path'); + if (is_string($base) && is_dir($base)) { + File::deleteDirectory($base); + } +}); + +test('isInstalled is false when launcher is missing', function (): void { + expect(app(VeraPdfService::class)->isInstalled())->toBeFalse(); +}); + +test('isInstalled is true when launcher exists and is executable', function (): void { + $base = config('verapdf.base_path'); + File::ensureDirectoryExists($base); + $launcher = $base.'/verapdf'; + file_put_contents($launcher, "#!/bin/sh\nexit 0\n"); + chmod($launcher, 0755); + + expect(app(VeraPdfService::class)->isInstalled())->toBeTrue(); +}); + +test('hasCliBinaries requires launcher and bin cli jar', function (): void { + $base = (string) config('verapdf.base_path'); + File::ensureDirectoryExists($base.'/bin'); + file_put_contents($base.'/verapdf', "#!/bin/sh\nexit 0\n"); + chmod($base.'/verapdf', 0755); + + $service = app(VeraPdfService::class); + expect($service->hasCliBinaries())->toBeFalse(); + + file_put_contents($base.'/bin/cli-1.30.1.jar', 'fake'); + expect($service->hasCliBinaries())->toBeTrue(); + expect($service->hasGuiArtefacts())->toBeFalse(); +}); + +test('hasGuiArtefacts detects gui launcher or jar', function (): void { + $base = (string) config('verapdf.base_path'); + File::ensureDirectoryExists($base.'/bin'); + file_put_contents($base.'/verapdf-gui', "#!/bin/sh\nexit 0\n"); + file_put_contents($base.'/bin/gui-1.30.1.jar', 'fake'); + + expect(app(VeraPdfService::class)->hasGuiArtefacts())->toBeTrue(); +}); + +test('javaAvailable reflects process result', function (): void { + Process::fake(fn () => Process::result( + output: '', + errorOutput: 'openjdk version "17"', + exitCode: 0, + )); + + expect(app(VeraPdfService::class)->javaAvailable())->toBeTrue(); + + Process::fake(fn () => Process::result( + output: '', + errorOutput: 'java: not found', + exitCode: 127, + )); + + expect(app(VeraPdfService::class)->javaAvailable())->toBeFalse(); +}); + +test('validate throws a clear error when not installed', function (): void { + Process::fake(fn () => Process::result( + output: '', + errorOutput: 'openjdk', + exitCode: 0, + )); + + expect(fn () => app(VeraPdfService::class)->validate('/tmp/missing.pdf')) + ->toThrow(RuntimeException::class, 'veraPDF is not installed'); +}); + +test('validate throws a clear error when java is missing', function (): void { + Process::fake(fn () => Process::result( + output: '', + errorOutput: 'java: not found', + exitCode: 127, + )); + + expect(fn () => app(VeraPdfService::class)->validate('/tmp/missing.pdf')) + ->toThrow(RuntimeException::class, 'Java not found'); +}); diff --git a/packages/verapdf/tests/bootstrap.php b/packages/verapdf/tests/bootstrap.php new file mode 100644 index 0000000000..7ab635825b --- /dev/null +++ b/packages/verapdf/tests/bootstrap.php @@ -0,0 +1,17 @@ +addPsr4('Moox\\VeraPdf\\', $packageRoot.'/src/'); + $autoload->addPsr4('Moox\\VeraPdf\\Tests\\', $packageRoot.'/tests/'); +} + +return $autoload; diff --git a/packages/verapdf/tests/fixtures/verapdf-report-fail.xml b/packages/verapdf/tests/fixtures/verapdf-report-fail.xml new file mode 100644 index 0000000000..4005648938 --- /dev/null +++ b/packages/verapdf/tests/fixtures/verapdf-report-fail.xml @@ -0,0 +1,23 @@ + + + + + + /tmp/sample-fail.pdf + + +
+ + If an image dictionary contains the Interpolate key, its value shall be false. + PDXImage + isInterpolate == false + + root/document[0]/pages[0]/contentStream[0]/operators[65]/xObject[0] + + +
+
+
+
+ +
diff --git a/packages/verapdf/tests/fixtures/verapdf-report-pass.xml b/packages/verapdf/tests/fixtures/verapdf-report-pass.xml new file mode 100644 index 0000000000..696362c94e --- /dev/null +++ b/packages/verapdf/tests/fixtures/verapdf-report-pass.xml @@ -0,0 +1,14 @@ + + + + + + /tmp/sample-pass.pdf + + +
+ + + + +