diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile deleted file mode 100644 index a3a7de4..0000000 --- a/.docker/php/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -ARG PHP_VERSION=8.3 - -FROM php:${PHP_VERSION}-alpine - -# Install system dependencies -RUN apk update && apk add --no-cache \ - $PHPIZE_DEPS \ - linux-headers \ - zlib-dev \ - libmemcached-dev \ - cyrus-sasl-dev - -RUN pecl install xdebug redis memcached \ - && docker-php-ext-enable xdebug redis memcached - -# Copy custom PHP configuration -COPY .docker/php/kariricode-php.ini /usr/local/etc/php/conf.d/ - -# Instalação do Composer -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - -RUN apk del --purge $PHPIZE_DEPS && rm -rf /var/cache/apk/* - -# Mantém o contêiner ativo sem fazer nada -CMD tail -f /dev/null diff --git a/.docker/php/kariricode-php.ini b/.docker/php/kariricode-php.ini deleted file mode 100644 index 9e90446..0000000 --- a/.docker/php/kariricode-php.ini +++ /dev/null @@ -1,14 +0,0 @@ -[PHP] -memory_limit = 256M -upload_max_filesize = 50M -post_max_size = 50M -date.timezone = America/Sao_Paulo - -[Xdebug] -; zend_extension=xdebug.so -xdebug.mode=debug -xdebug.start_with_request=yes -xdebug.client_host=host.docker.internal -xdebug.client_port=9003 -xdebug.log=/tmp/xdebug.log -xdebug.idekey=VSCODE diff --git a/.env.example b/.env.example deleted file mode 100644 index e461630..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -KARIRI_APP_ENV=develop -KARIRI_PHP_VERSION=8.3 -KARIRI_PHP_PORT=9003 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index 112620c..31f41b6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,5 +14,4 @@ /phpstan.neon export-ignore /phpunit.xml export-ignore /psalm.xml export-ignore -/Makefile export-ignore -/composer.lock \ No newline at end of file +/Makefile export-ignore \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6bbd010 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +# ARFA 1.3 / KaririCode Spec V4.0 — Unified CI Pipeline +# Runs on every push and PR targeting main or develop. +# Full pipeline: cs-fixer → phpstan (L9) → psalm → phpunit (pcov) +# Zero tolerance: any tool failure blocks the merge. + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + workflow_dispatch: + +jobs: + quality: + name: Quality Pipeline (ARFA 1.3) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # PHP 8.4 + pcov (mandatory driver per ARFA 1.3 §Testing) + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, xml + coverage: pcov + + # Pure dependency install — no scripts to avoid environment pollution + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress --no-scripts + + # Bootstrap kcode.phar from the official KaririCode release + - name: Install kcode (KaririCode Devkit) + run: | + wget -q https://github.com/KaririCode-Framework/kariricode-devkit/releases/latest/download/kcode.phar + chmod +x kcode.phar + sudo mv kcode.phar /usr/local/bin/kcode + + # Generate .kcode/ configs: phpunit.xml.dist, phpstan.neon, psalm.xml, etc. + - name: Initialize devkit (.kcode/ generation) + run: kcode init + + # cs-fixer → phpstan (L9) → psalm → phpunit + # Exit code ≠ 0 fails the job (zero-tolerance policy) + - name: Run full quality pipeline + run: kcode quality diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..c3053b7 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,204 @@ +name: Code Quality + +# ARFA 1.3 / KaririCode Spec V4.0 — Parallel Quality Gates +# Runs 5 parallel jobs with a quality-summary gate job. +# Triggers: main, develop, feature branches, PRs, and manual dispatch. + +on: + push: + branches: + - main + - develop + - 'feature/**' + pull_request: + branches: + - main + - develop + workflow_dispatch: + +jobs: + # ============================================================================ + # DEPENDENCY VALIDATION (Spec V4.0 — zero-dep contract) + # Validates that composer.json is valid and platform requirements are met. + # Sanitizer mandates: zero external runtime dependencies. + # ============================================================================ + dependencies: + name: Dependency Validation + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer:v2 + coverage: none + + - name: Validate composer.json + run: composer validate --strict --no-check-lock + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-scripts + + - name: Check platform requirements + run: composer check-platform-reqs + + # ============================================================================ + # SECURITY AUDIT (ARFA 1.3 — resilience pillar) + # Uses native composer audit — no deprecated security-checker. + # ============================================================================ + security: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer:v2 + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-scripts + + - name: Run composer audit + run: composer audit --format=plain + + # ============================================================================ + # STATIC ANALYSIS (Spec V4.0 S14 — Type Safety) + # kcode analyse runs PHPStan Level 9 + Psalm (100% type inference). + # Both tools must pass with zero errors — enforced by kcode exit code. + # ============================================================================ + analyse: + name: Static Analysis — PHPStan L9 + Psalm + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, xml + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-scripts + + - name: Install kcode + run: | + wget -q https://github.com/KaririCode-Framework/kariricode-devkit/releases/latest/download/kcode.phar + chmod +x kcode.phar + sudo mv kcode.phar /usr/local/bin/kcode + + - name: Initialize devkit + run: kcode init + + # Runs PHPStan Level 9 then Psalm sequentially — both must pass + - name: Run PHPStan + Psalm via kcode + run: kcode analyse + + # ============================================================================ + # CODE STYLE (ARFA 1.3 Naming / Formatting Standards) + # kcode cs:fix enforces PSR-12 + PHP 8.4 migrations + KaririCode rules. + # --check: dry-run only — fails if any violation exists. + # ============================================================================ + cs-fixer: + name: Code Style — PHP CS Fixer + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, xml + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-scripts + + - name: Install kcode + run: | + wget -q https://github.com/KaririCode-Framework/kariricode-devkit/releases/latest/download/kcode.phar + chmod +x kcode.phar + sudo mv kcode.phar /usr/local/bin/kcode + + - name: Initialize devkit + run: kcode init + + - name: Check code style (dry-run) + run: kcode cs:fix --check + + # ============================================================================ + # UNIT & INTEGRATION TESTS (ARFA 1.3 §Testing — Zero Tolerance) + # pcov is the mandatory driver (performance + accuracy over Xdebug). + # Requires: 0 failures, 0 errors, 0 warnings, 0 risky tests. + # ============================================================================ + tests: + name: PHPUnit Tests (pcov) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, xml + coverage: pcov + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-scripts + + - name: Install kcode + run: | + wget -q https://github.com/KaririCode-Framework/kariricode-devkit/releases/latest/download/kcode.phar + chmod +x kcode.phar + sudo mv kcode.phar /usr/local/bin/kcode + + - name: Initialize devkit + run: kcode init + + - name: Run tests with coverage (pcov) + run: kcode test --coverage + + # ============================================================================ + # QUALITY SUMMARY — Gate job (if: always()) + # Aggregates all job results and fails the workflow if any check failed. + # Posts a markdown summary to the GitHub Actions run. + # ============================================================================ + quality-summary: + name: Quality Summary + runs-on: ubuntu-latest + needs: [dependencies, security, analyse, cs-fixer, tests] + if: always() + + steps: + - name: Post quality summary + run: | + echo "## KaririCode Sanitizer — Quality Report (ARFA 1.3)" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Check | Result |" >> "$GITHUB_STEP_SUMMARY" + echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Dependency Validation | ${{ needs.dependencies.result }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Security Audit | ${{ needs.security.result }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Static Analysis (PHPStan L9 + Psalm) | ${{ needs.analyse.result }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Code Style (CS Fixer) | ${{ needs.cs-fixer.result }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| PHPUnit Tests (pcov) | ${{ needs.tests.result }} |" >> "$GITHUB_STEP_SUMMARY" + + if [ "${{ needs.security.result }}" != "success" ] || [ "${{ needs.analyse.result }}" != "success" ] || [ "${{ needs.cs-fixer.result }}" != "success" ] || [ "${{ needs.tests.result }}" != "success" ]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "❌ One or more quality gates failed. Merge blocked." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "✅ All quality gates passed — ARFA 1.3 compliant." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c1885d0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release + +# ARFA 1.3 / KaririCode Spec V4.0 — Release Pipeline +# Triggers on semantic version tags (v*). +# Full quality gate (kcode quality) must pass before release is published. + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Quality Gate + GitHub Release + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # PHP 8.4 + pcov: releases MUST pass with coverage (ARFA 1.3 §Testing) + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: mbstring, xml + coverage: pcov + tools: composer:v2 + + # --no-scripts prevents accidental environment pollution during release + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress --no-scripts + + - name: Install kcode (KaririCode Devkit) + run: | + wget -q https://github.com/KaririCode-Framework/kariricode-devkit/releases/latest/download/kcode.phar + chmod +x kcode.phar + sudo mv kcode.phar /usr/local/bin/kcode + + - name: Initialize devkit + run: kcode init + + # Full pipeline: cs-fixer → phpstan (L9) → psalm → phpunit (pcov) + # Exit code ≠ 0 aborts the release — zero tolerance (ARFA 1.3) + - name: Run full quality pipeline (release gate) + run: kcode quality + + - name: Extract version from tag + id: version + run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: KaririCode Sanitizer ${{ steps.version.outputs.tag }} + draft: false + prerelease: false + body: | + ## KaririCode\Sanitizer ${{ steps.version.outputs.tag }} + + PHP 8.4+ sanitizer engine — **zero external dependencies**, ARFA 1.3 compliant. + + ## Installation + + ```bash + composer require kariricode/sanitizer + ``` + + ## Quality Metrics + + | Metric | Value | + |--------|-------| + | PHPStan Level | 9 (0 errors) | + | Psalm | 100% (0 errors) | + | Coverage | 100% | + | Dependencies | 0 (runtime) | + + See [CHANGELOG.md](CHANGELOG.md) for details. diff --git a/.gitignore b/.gitignore index 5e5baad..2c36f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ tests/lista_de_arquivos_test.php lista_de_arquivos.txt lista_de_arquivos_tests.txt add_static_to_providers.php + +# KaririCode Devkit — generated configs and build artifacts +.kcode/ diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php deleted file mode 100644 index c3a51bb..0000000 --- a/.php-cs-fixer.php +++ /dev/null @@ -1,69 +0,0 @@ -in(__DIR__ . '/src') - ->in(__DIR__ . '/tests') - ->exclude('var') - ->exclude('config') - ->exclude('vendor'); - -return (new PhpCsFixer\Config()) - ->setParallelConfig(new PhpCsFixer\Runner\Parallel\ParallelConfig(4, 20)) - ->setRules([ - '@PSR12' => true, - '@Symfony' => true, - 'full_opening_tag' => false, - 'phpdoc_var_without_name' => false, - 'phpdoc_to_comment' => false, - 'array_syntax' => ['syntax' => 'short'], - 'concat_space' => ['spacing' => 'one'], - 'binary_operator_spaces' => [ - 'default' => 'single_space', - 'operators' => [ - '=' => 'single_space', - '=>' => 'single_space', - ], - ], - 'blank_line_before_statement' => [ - 'statements' => ['return'] - ], - 'cast_spaces' => ['space' => 'single'], - 'class_attributes_separation' => [ - 'elements' => [ - 'const' => 'none', - 'method' => 'one', - 'property' => 'none' - ] - ], - 'declare_equal_normalize' => ['space' => 'none'], - 'function_typehint_space' => true, - 'lowercase_cast' => true, - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'ordered_imports' => true, - 'phpdoc_align' => ['align' => 'left'], - 'phpdoc_no_alias_tag' => ['replacements' => ['type' => 'var', 'link' => 'see']], - 'phpdoc_order' => true, - 'phpdoc_scalar' => true, - 'single_quote' => true, - 'standardize_not_equals' => true, - 'trailing_comma_in_multiline' => ['elements' => ['arrays']], - 'trim_array_spaces' => true, - 'space_after_semicolon' => true, - 'no_spaces_inside_parenthesis' => true, - 'no_whitespace_before_comma_in_array' => true, - 'whitespace_after_comma_in_array' => true, - 'visibility_required' => ['elements' => ['const', 'method', 'property']], - 'multiline_whitespace_before_semicolons' => [ - 'strategy' => 'no_multi_line', - ], - 'method_chaining_indentation' => true, - 'class_definition' => [ - 'single_item_single_line' => false, - 'multi_line_extends_each_single_line' => true, - ], - 'not_operator_with_successor_space' => false - ]) - ->setRiskyAllowed(true) - ->setFinder($finder) - ->setUsingCache(false); diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 38f7f80..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "[php]": { - "editor.defaultFormatter": "junstyle.php-cs-fixer" - }, - "php-cs-fixer.executablePath": "${workspaceFolder}/vendor/bin/php-cs-fixer", - "php-cs-fixer.onsave": true, - "php-cs-fixer.rules": "@PSR12", - "php-cs-fixer.config": ".php_cs.dist", - "php-cs-fixer.formatHtml": true -} diff --git a/Makefile b/Makefile deleted file mode 100644 index 93b6b81..0000000 --- a/Makefile +++ /dev/null @@ -1,174 +0,0 @@ -# Initial configurations -PHP_SERVICE := kariricode-sanitizer -DC := docker-compose - -# Command to execute commands inside the PHP container -EXEC_PHP := $(DC) exec -T php - -# Icons -CHECK_MARK := ✅ -WARNING := ⚠️ -INFO := ℹ️ - -# Colors -RED := \033[0;31m -GREEN := \033[0;32m -YELLOW := \033[1;33m -NC := \033[0m # No Color - -# Check if Docker is installed -CHECK_DOCKER := @command -v docker > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} Docker is not installed. Aborting.${NC}"; exit 1; } -# Check if Docker Compose is installed -CHECK_DOCKER_COMPOSE := @command -v docker-compose > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} Docker Compose is not installed. Aborting.${NC}"; exit 1; } -# Function to check if the container is running -CHECK_CONTAINER_RUNNING := @docker ps | grep $(PHP_SERVICE) > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} The container $(PHP_SERVICE) is not running. Run 'make up' to start it.${NC}"; exit 1; } -# Check if the .env file exists -CHECK_ENV := @test -f .env || { echo >&2 "${YELLOW}${WARNING} .env file not found. Run 'make setup-env' to configure.${NC}"; exit 1; } - -## setup-env: Copy .env.example to .env if the latter does not exist -setup-env: - @test -f .env || (cp .env.example .env && echo "${GREEN}${CHECK_MARK} .env file created successfully from .env.example${NC}") - -check-environment: - @echo "${GREEN}${INFO} Checking Docker, Docker Compose, and .env file...${NC}" - $(CHECK_DOCKER) - $(CHECK_DOCKER_COMPOSE) - $(CHECK_ENV) - -check-container-running: - $(CHECK_CONTAINER_RUNNING) - -## up: Start all services in the background -up: check-environment - @echo "${GREEN}${INFO} Starting services...${NC}" - @$(DC) up -d - @echo "${GREEN}${CHECK_MARK} Services are up!${NC}" - -## down: Stop and remove all containers -down: check-environment - @echo "${YELLOW}${INFO} Stopping and removing services...${NC}" - @$(DC) down - @echo "${GREEN}${CHECK_MARK} Services stopped and removed!${NC}" - -## build: Build Docker images -build: check-environment - @echo "${YELLOW}${INFO} Building services...${NC}" - @$(DC) build - @echo "${GREEN}${CHECK_MARK} Services built!${NC}" - -## logs: Show container logs -logs: check-environment - @echo "${YELLOW}${INFO} Container logs:${NC}" - @$(DC) logs - -## re-build: Rebuild and restart containers -re-build: check-environment - @echo "${YELLOW}${INFO} Stopping and removing current services...${NC}" - @$(DC) down - @echo "${GREEN}${INFO} Rebuilding services...${NC}" - @$(DC) build - @echo "${GREEN}${INFO} Restarting services...${NC}" - @$(DC) up -d - @echo "${GREEN}${CHECK_MARK} Services rebuilt and restarted successfully!${NC}" - @$(DC) logs - -## shell: Access the shell of the PHP container -shell: check-environment check-container-running - @echo "${GREEN}${INFO} Accessing the shell of the PHP container...${NC}" - @$(DC) exec php sh - -## composer-install: Install Composer dependencies. Use make composer-install [PKG="[vendor/package [version]]"] [DEV="--dev"] -composer-install: check-environment check-container-running - @echo "${GREEN}${INFO} Installing Composer dependencies...${NC}" - @if [ -z "$(PKG)" ]; then \ - $(EXEC_PHP) composer install; \ - else \ - $(EXEC_PHP) composer require $(PKG) $(DEV); \ - fi - @echo "${GREEN}${CHECK_MARK} Composer operation completed!${NC}" - -## composer-remove: Remove Composer dependencies. Usage: make composer-remove PKG="vendor/package" -composer-remove: check-environment check-container-running - @if [ -z "$(PKG)" ]; then \ - echo "${RED}${WARNING} You must specify a package to remove. Usage: make composer-remove PKG=\"vendor/package\"${NC}"; \ - else \ - $(EXEC_PHP) composer remove $(PKG); \ - echo "${GREEN}${CHECK_MARK} Package $(PKG) removed successfully!${NC}"; \ - fi - -## composer-update: Update Composer dependencies -composer-update: check-environment check-container-running - @echo "${GREEN}${INFO} Updating Composer dependencies...${NC}" - $(EXEC_PHP) composer update - @echo "${GREEN}${CHECK_MARK} Dependencies updated!${NC}" - -## test: Run tests -test: check-environment check-container-running - @echo "${GREEN}${INFO} Running tests...${NC}" - $(EXEC_PHP) ./vendor/bin/phpunit --testdox --colors=always tests - @echo "${GREEN}${CHECK_MARK} Tests completed!${NC}" - -## test-file: Run tests on a specific class. Usage: make test-file FILE=[file] -test-file: check-environment check-container-running - @echo "${GREEN}${INFO} Running test for class $(FILE)...${NC}" - $(EXEC_PHP) ./vendor/bin/phpunit --testdox --colors=always tests/$(FILE) - @echo "${GREEN}${CHECK_MARK} Test for class $(FILE) completed!${NC}" - -## coverage: Run test coverage with visual formatting -coverage: check-environment check-container-running - @echo "${GREEN}${INFO} Analyzing test coverage...${NC}" - XDEBUG_MODE=coverage $(EXEC_PHP) ./vendor/bin/phpunit --coverage-text --colors=always tests | ccze -A - -## coverage-html: Run test coverage and generate HTML report -coverage-html: check-environment check-container-running - @echo "${GREEN}${INFO} Analyzing test coverage and generating HTML report...${NC}" - XDEBUG_MODE=coverage $(EXEC_PHP) ./vendor/bin/phpunit --coverage-html ./coverage-report-html tests - @echo "${GREEN}${INFO} Test coverage report generated in ./coverage-report-html${NC}" - -## run-script: Run a PHP script. Usage: make run-script SCRIPT="path/to/script.php" -run-script: check-environment check-container-running - @echo "${GREEN}${INFO} Running script: $(SCRIPT)...${NC}" - $(EXEC_PHP) php $(SCRIPT) - @echo "${GREEN}${CHECK_MARK} Script executed!${NC}" - -## cs-check: Run PHP_CodeSniffer to check code style -cs-check: check-environment check-container-running - @echo "${GREEN}${INFO} Checking code style...${NC}" - $(EXEC_PHP) ./vendor/bin/php-cs-fixer fix --dry-run --diff - @echo "${GREEN}${CHECK_MARK} Code style check completed!${NC}" - -## cs-fix: Run PHP CS Fixer to fix code style -cs-fix: check-environment check-container-running - @echo "${GREEN}${INFO} Fixing code style with PHP CS Fixer...${NC}" - $(EXEC_PHP) ./vendor/bin/php-cs-fixer fix - @echo "${GREEN}${CHECK_MARK} Code style fixed!${NC}" - -## security-check: Check for security vulnerabilities in dependencies -security-check: check-environment check-container-running - @echo "${GREEN}${INFO} Checking for security vulnerabilities with Security Checker...${NC}" - $(EXEC_PHP) ./vendor/bin/security-checker security:check - @echo "${GREEN}${CHECK_MARK} Security check completed!${NC}" - -## quality: Run all quality commands -quality: check-environment check-container-running cs-check test security-check - @echo "${GREEN}${CHECK_MARK} All quality commands executed!${NC}" - -## help: Show initial setup steps and available commands -help: - @echo "${GREEN}Initial setup steps for configuring the project:${NC}" - @echo "1. ${YELLOW}Initial environment setup:${NC}" - @echo " ${GREEN}${CHECK_MARK} Copy the environment file:${NC} make setup-env" - @echo " ${GREEN}${CHECK_MARK} Start the Docker containers:${NC} make up" - @echo " ${GREEN}${CHECK_MARK} Install Composer dependencies:${NC} make composer-install" - @echo "2. ${YELLOW}Development:${NC}" - @echo " ${GREEN}${CHECK_MARK} Access the PHP container shell:${NC} make shell" - @echo " ${GREEN}${CHECK_MARK} Run a PHP script:${NC} make run-script SCRIPT=\"script_name.php\"" - @echo " ${GREEN}${CHECK_MARK} Run the tests:${NC} make test" - @echo "3. ${YELLOW}Maintenance:${NC}" - @echo " ${GREEN}${CHECK_MARK} Update Composer dependencies:${NC} make composer-update" - @echo " ${GREEN}${CHECK_MARK} Clear the application cache:${NC} make cache-clear" - @echo " ${RED}${WARNING} Stop and remove all Docker containers:${NC} make down" - @echo "\n${GREEN}Available commands:${NC}" - @sed -n 's/^##//p' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ": "}; {printf "${YELLOW}%-30s${NC} %s\n", $$1, $$2}' - -.PHONY: setup-env up down build logs re-build shell composer-install composer-remove composer-update test test-file coverage coverage-html run-script cs-check cs-fix security-check quality help diff --git a/README.md b/README.md index 27de861..894462c 100644 --- a/README.md +++ b/README.md @@ -1,385 +1,314 @@ -# KaririCode Framework: Sanitizer Component - -A robust and flexible data sanitization component for PHP, part of the KaririCode Framework. It utilizes configurable processors and native functions to ensure data integrity and security in your applications. - -## Table of Contents - -- [Features](#features) -- [Installation](#installation) -- [Usage](#usage) - - [Basic Usage](#basic-usage) - - [Advanced Usage: Blog Post Sanitization](#advanced-usage-blog-post-sanitization) -- [Available Sanitizers](#available-sanitizers) - - [Input Sanitizers](#input-sanitizers) - - [Domain Sanitizers](#domain-sanitizers) - - [Security Sanitizers](#security-sanitizers) -- [Configuration](#configuration) -- [Integration with Other KaririCode Components](#integration-with-other-kariricode-components) -- [Development and Testing](#development-and-testing) -- [Contributing](#contributing) -- [License](#license) -- [Support and Community](#support-and-community) - -## Features - -- Flexible attribute-based sanitization for object properties -- Comprehensive set of built-in sanitizers for common use cases -- Easy integration with other KaririCode components -- Configurable processors for customized sanitization logic -- Support for fallback values in case of sanitization failures -- Extensible architecture allowing custom sanitizers -- Robust error handling and reporting -- Chainable sanitization pipelines for complex data transformations -- Built-in support for multiple character encodings -- Protection against XSS and SQL injection attacks +# KaririCode Sanitizer -## Installation +
-You can install the Sanitizer component via Composer: +[![PHP 8.4+](https://img.shields.io/badge/PHP-8.4%2B-777BB4?logo=php&logoColor=white)](https://www.php.net/) +[![License: MIT](https://img.shields.io/badge/License-MIT-22c55e.svg)](LICENSE) +[![PHPStan Level 9](https://img.shields.io/badge/PHPStan-Level%209-4F46E5)](https://phpstan.org/) +[![Rules](https://img.shields.io/badge/Rules-33-22c55e)](https://kariricode.org) +[![Zero Dependencies](https://img.shields.io/badge/Dependencies-0-22c55e)](composer.json) +[![ARFA](https://img.shields.io/badge/ARFA-1.3-orange)](https://kariricode.org) +[![KaririCode Framework](https://img.shields.io/badge/KaririCode-Framework-orange)](https://kariricode.org) -```bash -composer require kariricode/sanitizer -``` +**Composable, rule-based data sanitization engine for PHP 8.4+ — 33 rules, zero dependencies.** -### Requirements +[Installation](#installation) · [Quick Start](#quick-start) · [XSS Prevention](#xss-prevention) · [All Rules](#all-33-rules) · [Architecture](#architecture) -- PHP 8.3 or higher -- Composer -- Extensions: `ext-mbstring`, `ext-dom`, `ext-libxml` +
-## Usage +--- -### Basic Usage +## The Problem -1. Define your data class with sanitization attributes: +Raw user input arrives dirty — whitespace, wrong case, dangerous HTML, unformatted documents — and cleaning it is always ad-hoc: ```php -use KaririCode\Sanitizer\Attribute\Sanitize; - -class UserProfile -{ - #[Sanitize(processors: ['trim', 'html_special_chars'])] - private string $name = ''; - - #[Sanitize(processors: ['trim', 'email_sanitizer'])] - private string $email = ''; - - // Getters and setters... -} +// Sprinkled everywhere with no audit trail +$name = ucwords(strtolower(trim($request->name))); +$email = strtolower(trim($request->email)); +$cpf = preg_replace('/\D/', '', $request->cpf); +$input = htmlspecialchars(strip_tags($request->bio)); + +// No record of what changed, no idempotency guarantee, +// no attribute-driven DTOs, no composition. ``` -2. Set up the sanitizer and use it: +## The Solution ```php -use KaririCode\ProcessorPipeline\ProcessorRegistry; -use KaririCode\Sanitizer\Sanitizer; -use KaririCode\Sanitizer\Processor\Input\TrimSanitizer; -use KaririCode\Sanitizer\Processor\Input\HtmlSpecialCharsSanitizer; -use KaririCode\Sanitizer\Processor\Input\EmailSanitizer; - -$registry = new ProcessorRegistry(); -$registry->register('sanitizer', 'trim', new TrimSanitizer()); -$registry->register('sanitizer', 'html_special_chars', new HtmlSpecialCharsSanitizer()); -$registry->register('sanitizer', 'email_sanitizer', new EmailSanitizer()); - -$sanitizer = new Sanitizer($registry); - -$userProfile = new UserProfile(); -$userProfile->setName(" Walmir Silva "); -$userProfile->setEmail(" walmir.silva@gmail.con "); - -$result = $sanitizer->sanitize($userProfile); - -echo $userProfile->getName(); // Output: "Walmir Silva" -echo $userProfile->getEmail(); // Output: "walmir.silva@gmail.com" +use KaririCode\Sanitizer\Provider\SanitizerServiceProvider; + +$engine = (new SanitizerServiceProvider())->createEngine(); + +$result = $engine->sanitize( + data: [ + 'name' => ' walmir SILVA ', + 'email' => ' Admin@Kariricode.ORG ', + 'cpf' => '52998224725', + 'bio' => 'Bold', + ], + fieldRules: [ + 'name' => ['trim', 'normalize_whitespace', 'capitalize'], + 'email' => ['trim', 'lower_case', 'email_filter'], + 'cpf' => ['format_cpf'], + 'bio' => ['strip_tags', 'html_encode'], + ], +); + +echo $result->get('name'); // "Walmir Silva" +echo $result->get('email'); // "admin@kariricode.org" +echo $result->get('cpf'); // "529.982.247-25" +echo $result->get('bio'); // "<script>alert(...)...Bold" ``` -### Advanced Usage: Blog Post Sanitization - -Here's an example of how to use the KaririCode Sanitizer in a real-world scenario, such as sanitizing blog post content: +--- -```php -use KaririCode\Sanitizer\Attribute\Sanitize; +## Requirements -class BlogPost -{ - #[Sanitize( - processors: ['trim', 'html_special_chars', 'xss_sanitizer'], - messages: [ - 'trim' => 'Title was trimmed', - 'html_special_chars' => 'Special characters in title were escaped', - 'xss_sanitizer' => 'XSS attempt was removed from title', - ] - )] - private string $title = ''; - - #[Sanitize( - processors: ['trim', 'markdown', 'html_purifier'], - messages: [ - 'trim' => 'Content was trimmed', - 'markdown' => 'Markdown in content was processed', - 'html_purifier' => 'HTML in content was purified', - ] - )] - private string $content = ''; - - // Getters and setters... -} +| Requirement | Version | +|---|---| +| PHP | 8.4 or higher | +| kariricode/property-inspector | ^2.0 | -// Usage example -$blogPost = new BlogPost(); -$blogPost->setTitle(" Exploring KaririCode: A Modern PHP Framework "); -$blogPost->setContent("# Introduction\nKaririCode is a **powerful** and _flexible_ PHP framework designed for modern web development."); +--- -$result = $sanitizer->sanitize($blogPost); +## Installation -// Access sanitized data -echo $blogPost->getTitle(); // Sanitized title -echo $blogPost->getContent(); // Sanitized content +```bash +composer require kariricode/sanitizer ``` -## Available Sanitizers - -### Input Sanitizers - -- **TrimSanitizer**: Removes whitespace from the beginning and end of a string. - - - **Configuration Options**: - - `characterMask`: Specifies which characters to trim. Default is whitespace. - - `trimLeft`: Boolean to trim from the left side. Default is `true`. - - `trimRight`: Boolean to trim from the right side. Default is `true`. - -- **HtmlSpecialCharsSanitizer**: Converts special characters to HTML entities to prevent XSS attacks. - - - **Configuration Options**: - - `flags`: Configurable flags like `ENT_QUOTES | ENT_HTML5`. - - `encoding`: Character encoding, e.g., 'UTF-8'. - - `doubleEncode`: Boolean to prevent double encoding. Default is `true`. - -- **NormalizeLineBreaksSanitizer**: Standardizes line breaks across different operating systems. +--- - - **Configuration Options**: - - `lineEnding`: Specifies line ending style. Options: 'unix', 'windows', 'mac'. +## Quick Start -- **EmailSanitizer**: Validates and corrects common email typos, normalizes email format, and handles whitespace. +```php +createEngine(); -- **AlphanumericSanitizer**: Removes non-alphanumeric characters, with configurable options to allow certain special characters. +$result = $engine->sanitize( + data: ['name' => ' walmir SILVA ', 'email' => ' Admin@Example.ORG '], + fieldRules: [ + 'name' => ['trim', 'normalize_whitespace', 'capitalize'], + 'email' => ['trim', 'lower_case'], + ], +); - - **Configuration Options**: - - `allowSpace`, `allowUnderscore`, `allowDash`, `allowDot`: Boolean options to allow specific characters. - - `preserveCase`: Boolean to maintain case sensitivity. +echo $result->get('name'); // "Walmir Silva" +echo $result->get('email'); // "admin@example.org" +``` -- **UrlSanitizer**: Validates and normalizes URLs, ensuring proper protocol and structure. +--- - - **Configuration Options**: - - `enforceProtocol`: Enforces a specific protocol, e.g., 'https://'. - - `defaultProtocol`: The protocol to apply if none is present. - - `removeTrailingSlash`: Boolean to remove trailing slash. +## Attribute-Driven DTO Sanitization -- **NumericSanitizer**: Ensures that the input is a numeric value, with options for decimal and negative numbers. +```php +use KaririCode\Sanitizer\Attribute\Sanitize; - - **Configuration Options**: - - `allowDecimal`, `allowNegative`: Boolean options to allow decimals and negative values. - - `decimalSeparator`: Specifies the character used for decimals. +final class CreateUserRequest +{ + #[Sanitize('trim', 'lower_case')] + public string $email = ' User@Test.COM '; -- **StripTagsSanitizer**: Removes HTML and PHP tags from input, with configurable options for allowed tags. - - **Configuration Options**: - - `allowedTags`: List of HTML tags to keep. - - `keepSafeAttributes`: Boolean to keep certain safe attributes. - - `safeAttributes`: Array of attributes to preserve. + #[Sanitize('trim', 'capitalize')] + public string $name = ' walmir silva '; -### Domain Sanitizers + #[Sanitize('format_cpf')] + public string $cpf = '52998224725'; +} -- **HtmlPurifierSanitizer**: Sanitizes HTML content by removing unsafe tags and attributes, ensuring safe HTML rendering. +$sanitizer = (new SanitizerServiceProvider())->createAttributeSanitizer(); +$result = $sanitizer->sanitize(new CreateUserRequest()); - - **Configuration Options**: - - `allowedTags`: Specifies which tags are allowed. - - `allowedAttributes`: Defines allowed attributes for each tag. - - `removeEmptyTags`, `removeComments`: Boolean to remove empty tags or HTML comments. - - `htmlEntities`: Convert characters to HTML entities. Default is `true`. +// $dto->email === 'user@test.com' +// $dto->name === 'Walmir Silva' +// $dto->cpf === '529.982.247-25' +``` -- **JsonSanitizer**: Validates and prettifies JSON strings, removes invalid characters, and ensures proper JSON structure. +--- - - **Configuration Options**: - - `prettyPrint`: Boolean to format JSON for readability. - - `removeInvalidCharacters`: Boolean to remove invalid characters from JSON. - - `validateUnicode`: Boolean to validate Unicode characters. +## Modification Tracking -- **MarkdownSanitizer**: Processes and sanitizes Markdown content, escaping special characters and preserving the Markdown structure. - - **Configuration Options**: - - `allowedElements`: Specifies allowed Markdown elements (e.g., 'p', 'h1', 'a'). - - `escapeSpecialCharacters`: Boolean to escape special characters like '\*', '\_', etc. - - `preserveStructure`: Boolean to maintain Markdown formatting. +Every change is logged with before/after values — full audit trail for free: -### Security Sanitizers +```php +$result = $engine->sanitize( + ['name' => ' Walmir '], + ['name' => ['trim', 'upper_case']], +); -- **FilenameSanitizer**: Ensures filenames are safe for use in file systems by removing unsafe characters and validating extensions. +$result->wasModified(); // true +$result->modifiedFields(); // ['name'] +$result->modificationCount(); // 2 - - **Configuration Options**: - - `replacement`: Character used to replace unsafe characters. Default is `'-'`. - - `preserveExtension`: Boolean to keep the file extension. - - `blockDangerousExtensions`: Boolean to block extensions like '.exe', '.js'. - - `allowedExtensions`: Array of allowed extensions. +foreach ($result->modificationsFor('name') as $mod) { + echo "{$mod->ruleName}: '{$mod->before}' → '{$mod->after}'\n"; +} +// string.trim: ' Walmir ' → 'Walmir' +// string.upper_case: 'Walmir' → 'WALMIR' +``` -- **SqlInjectionSanitizer**: Protects against SQL injection attacks by escaping special characters and removing potentially harmful content. +--- - - **Configuration Options**: - - `escapeMap`: Array of characters to escape. - - `removeComments`: Boolean to strip SQL comments. - - `escapeQuotes`: Boolean to escape quotes in SQL queries. +## XSS Prevention -- **XssSanitizer**: Prevents Cross-Site Scripting (XSS) attacks by removing malicious scripts, attributes, and ensuring safe HTML output. - - **Configuration Options**: - - `removeScripts`: Boolean to remove `Bold'], + ['input' => ['strip_tags', 'html_encode']], +); +// Result: "<script>alert("xss")</script>Bold" +// Or with strip_tags alone: 'alert("xss")Bold' +``` -## Configuration +--- -The Sanitizer component can be configured globally or per-sanitizer basis. Here's an example of how to configure the `HtmlPurifierSanitizer`: +## Brazilian Document Formatting ```php -use KaririCode\Sanitizer\Processor\Domain\HtmlPurifierSanitizer; - -$htmlPurifier = new HtmlPurifierSanitizer(); -$htmlPurifier->configure([ - 'allowedTags' => ['p', 'br', 'strong', 'em'], - 'allowedAttributes' => ['href' => ['a'], 'src' => ['img']], -]); - -$registry->register('sanitizer', 'html_purifier', $htmlPurifier); +$result = $engine->sanitize( + ['cpf' => '52998224725', 'cnpj' => '11222333000181', 'cep' => '63100000'], + ['cpf' => ['format_cpf'], 'cnpj' => ['format_cnpj'], 'cep' => ['format_cep']], +); +// cpf: "529.982.247-25" +// cnpj: "11.222.333/0001-81" +// cep: "63100-000" ``` -For global configuration options, refer to the `Sanitizer` class constructor. - -## Integration with Other KaririCode Components - -The Sanitizer component is designed to work seamlessly with other KaririCode components: +--- -- **KaririCode\Contract**: Provides interfaces and contracts for consistent component integration. -- **KaririCode\ProcessorPipeline**: Utilized for building and executing sanitization pipelines. -- **KaririCode\PropertyInspector**: Used for analyzing and processing object properties with sanitization attributes. +## All 33 Rules -## Registry Explanation +| Category | Rules | Aliases | +|---|---|---| +| **String** (12) | Trim, LowerCase, UpperCase, Capitalize, Slug, Truncate, NormalizeWhitespace, NormalizeLineEndings, Pad, Replace, RegexReplace, StripNonPrintable | `trim`, `lower_case`, `upper_case`, `capitalize`, `slug`, `truncate`, `normalize_whitespace`, `normalize_line_endings`, `pad`, `replace`, `regex_replace`, `strip_non_printable` | +| **HTML** (5) | StripTags, HtmlEncode, HtmlDecode, HtmlPurify, UrlEncode | `strip_tags`, `html_encode`, `html_decode`, `html_purify`, `url_encode` | +| **Numeric** (4) | ToInt, ToFloat, Clamp, Round | `to_int`, `to_float`, `clamp`, `round` | +| **Type** (3) | ToBool, ToString, ToArray | `to_bool`, `to_string`, `to_array` | +| **Date** (2) | NormalizeDate, TimestampToDate | `normalize_date`, `timestamp_to_date` | +| **Filter** (4) | DigitsOnly, AlphaOnly, AlphanumericOnly, EmailFilter | `digits_only`, `alpha_only`, `alphanumeric_only`, `email_filter` | +| **Brazilian** (3) | FormatCPF, FormatCNPJ, FormatCEP | `format_cpf`, `format_cnpj`, `format_cep` | -The registry is a core part of how sanitizers are managed within the KaririCode Framework. It acts as a centralized location to register and configure all sanitizers you plan to use in your application. +--- -Here's how you can create and configure the registry: +## Engine API (Programmatic) ```php -// Create and configure the registry -$registry = new ProcessorRegistry(); - -// Register all required processors -$registry->register('sanitizer', 'trim', new TrimSanitizer()); -$registry->register('sanitizer', 'html_special_chars', new HtmlSpecialCharsSanitizer()); -$registry->register('sanitizer', 'normalize_line_breaks', new NormalizeLineBreaksSanitizer()); -$registry->register('sanitizer', 'html_purifier', new HtmlPurifierSanitizer()); -$registry->register('sanitizer', 'markdown', new MarkdownSanitizer()); -$registry->register('sanitizer', 'numeric_sanitizer', new NumericSanitizer()); -$registry->register('sanitizer', 'email_sanitizer', new EmailSanitizer()); -$registry->register('sanitizer', 'phone_sanitizer', new PhoneSanitizer()); -$registry->register('sanitizer', 'url_sanitizer', new UrlSanitizer()); -$registry->register('sanitizer', 'alphanumeric_sanitizer', new AlphanumericSanitizer()); -$registry->register('sanitizer', 'filename_sanitizer', new FilenameSanitizer()); -$registry->register('sanitizer', 'json_sanitizer', new JsonSanitizer()); -$registry->register('sanitizer', 'xss_sanitizer', new XssSanitizer()); -$registry->register('sanitizer', 'sql_injection', new SqlInjectionSanitizer()); -$registry->register('sanitizer', 'strip_tags', new StripTagsSanitizer()); -``` +$engine = (new SanitizerServiceProvider())->createEngine(); -This code demonstrates how to register various sanitizers with the registry, allowing you to easily manage which sanitizers are available throughout your application. Each sanitizer is given a unique identifier, which can then be referenced in attributes to apply specific sanitization rules. +$result = $engine->sanitize( + ['html' => 'test', 'text' => ' spaces '], + ['html' => ['strip_tags', 'trim'], 'text' => ['trim', 'upper_case']], +); -## Development and Testing +$result->get('html'); // "test" +$result->get('text'); // "SPACES" +$result->wasModified(); // true +$result->modifiedFields(); // ['html', 'text'] +$result->modificationCount(); // 4 -For development and testing purposes, this package uses Docker and Docker Compose to ensure consistency across different environments. A Makefile is provided for convenience. +foreach ($result->modificationsFor('html') as $mod) { + echo "{$mod->ruleName}: '{$mod->before}' → '{$mod->after}'\n"; +} +// html.strip_tags: 'test' → 'test' +``` -### Prerequisites +--- -- Docker -- Docker Compose -- Make (optional, but recommended for easier command execution) +## Ecosystem Position -### Development Setup +``` +DPO Pipeline: Validator → ★ Sanitizer ★ → Transformer → Business Logic +Infra Pipeline: Object ↔ Normalizer ↔ Array ↔ Serializer ↔ String +Cross-Layer: Request DTO ↔ Mapper ↔ Domain Entity ↔ Mapper ↔ Response DTO +``` -1. Clone the repository: +The Sanitizer **cleans data** — removes noise while preserving semantic meaning. Key property: idempotency — `sanitize(sanitize(x)) = sanitize(x)`. Contrast with the Transformer, which converts representation and may change type. - ```bash - git clone https://github.com/KaririCode-Framework/kariricode-sanitizer.git - cd kariricode-sanitizer - ``` +--- -2. Set up the environment: +## Architecture - ```bash - make setup-env - ``` +### Source layout -3. Start the Docker containers: +``` +src/ +├── Attribute/ Sanitize — field-level sanitization annotation +├── Contract/ SanitizationRule · SanitizationContext · SanitizerEngine · Modification +├── Core/ SanitizerEngine · SanitizationContextImpl · InMemoryRuleRegistry +├── Exception/ SanitizationException · InvalidRuleException +├── Provider/ SanitizerServiceProvider — factory for engine & attribute sanitizer +└── Rule/ + ├── Brazilian/ FormatCPF · FormatCNPJ · FormatCEP + ├── Date/ NormalizeDate · TimestampToDate + ├── Filter/ DigitsOnly · AlphaOnly · AlphanumericOnly · EmailFilter + ├── HTML/ StripTags · HtmlEncode · HtmlDecode · HtmlPurify · UrlEncode + ├── Numeric/ ToInt · ToFloat · Clamp · Round + ├── String/ Trim · LowerCase · UpperCase · Capitalize · Slug · Truncate · … + └── Type/ ToBool · ToString · ToArray +``` - ```bash - make up - ``` +### Key design decisions -4. Install dependencies: +| Decision | Rationale | ADR | +|---|---|---| +| Idempotency guarantee | `sanitize(sanitize(x)) = sanitize(x)` for all rules | [ADR-001](docs/adr/ADR-001-idempotency.md) | +| Modification tracking | Full audit trail without extra overhead | [ADR-002](docs/adr/ADR-002-modification-tracking.md) | +| `final readonly` rules | Immutability, PHPStan L9 | [ADR-003](docs/adr/ADR-003-immutable-rules.md) | - ```bash - make composer-install - ``` +### Specifications -### Available Make Commands +| Spec | Covers | +|---|---| +| [SPEC-001](docs/spec/SPEC-001-sanitization-contract.md) | Rule contract and idempotency | +| [SPEC-002](docs/spec/SPEC-002-modification-tracking.md) | Modification record format | -- `make up`: Start all services in the background -- `make down`: Stop and remove all containers -- `make build`: Build Docker images -- `make shell`: Access the PHP container shell -- `make test`: Run tests -- `make coverage`: Run test coverage with visual formatting -- `make cs-fix`: Run PHP CS Fixer to fix code style -- `make quality`: Run all quality commands (cs-check, test, security-check) +--- -For a full list of available commands, run: +## Project Stats + +| Metric | Value | +|---|---| +| PHP source files | 50 | +| Source lines | 1,913 | +| Test files | 15 | +| Test lines | 969 | +| External runtime dependencies | 1 (kariricode/property-inspector) | +| Rule classes | 33 | +| Rule categories | 7 | +| PHPStan level | 9 | +| PHP version | 8.4+ | +| ARFA compliance | 1.3 | -```bash -make help -``` +--- ## Contributing -We welcome contributions to the KaririCode Sanitizer component! Here's how you can contribute: - -1. Fork the repository -2. Create a new branch for your feature or bug fix -3. Write tests for your changes -4. Implement your changes -5. Run the test suite and ensure all tests pass -6. Submit a pull request with a clear description of your changes +```bash +git clone https://github.com/KaririCode-Framework/kariricode-sanitizer.git +cd kariricode-sanitizer +composer install +kcode init +kcode quality # Must pass before opening a PR +``` -Please read our [Contributing Guide](CONTRIBUTING.md) for more details on our code of conduct and development process. +--- ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +[MIT License](LICENSE) © [Walmir Silva](mailto:community@kariricode.org) + +--- -## Support and Community +
-- **Documentation**: [https://kariricode.org/docs/sanitizer](https://kariricode.org/docs/sanitizer) -- **Issue Tracker**: [GitHub Issues](https://github.com/KaririCode-Framework/kariricode-sanitizer/issues) -- **Community Forum**: [KaririCode Club Community](https://kariricode.club) -- **Stack Overflow**: Tag your questions with `kariricode-sanitizer` +Part of the **[KaririCode Framework](https://kariricode.org)** ecosystem. ---- +[kariricode.org](https://kariricode.org) · [GitHub](https://github.com/KaririCode-Framework/kariricode-sanitizer) · [Packagist](https://packagist.org/packages/kariricode/sanitizer) · [Issues](https://github.com/KaririCode-Framework/kariricode-sanitizer/issues) -Built with ❤️ by the KaririCode team. Empowering developers to create more secure and robust PHP applications. +
diff --git a/README.pt-br.md b/README.pt-br.md deleted file mode 100644 index 8fc72aa..0000000 --- a/README.pt-br.md +++ /dev/null @@ -1,386 +0,0 @@ -# KaririCode Framework: Componente Sanitizador - -Um componente robusto e flexível de sanitização de dados para PHP, parte do KaririCode Framework. Utiliza processadores configuráveis e funções nativas para garantir a integridade e segurança dos dados em suas aplicações. - -## Índice - -- [Funcionalidades](#funcionalidades) -- [Instalação](#instalacao) -- [Uso](#uso) - - [Uso Básico](#uso-basico) - - [Uso Avançado: Sanitização de Postagem de Blog](#uso-avancado-sanitizacao-de-postagem-de-blog) -- [Sanitizadores Disponíveis](#sanitizadores-disponiveis) - - [Sanitizadores de Entrada](#sanitizadores-de-entrada) - - [Sanitizadores de Domínio](#sanitizadores-de-dominio) - - [Sanitizadores de Segurança](#sanitizadores-de-seguranca) -- [Configuração](#configuracao) -- [Integração com Outros Componentes do KaririCode](#integracao-com-outros-componentes-do-kariricode) -- [Desenvolvimento e Testes](#desenvolvimento-e-testes) -- [Contribuindo](#contribuindo) -- [Licença](#licenca) -- [Suporte e Comunidade](#suporte-e-comunidade) - -## Funcionalidades - -- Sanitização flexível baseada em atributos para propriedades de objetos -- Conjunto abrangente de sanitizadores para casos de uso comuns -- Fácil integração com outros componentes do KaririCode -- Processadores configuráveis para lógica de sanitização personalizada -- Suporte para valores de fallback em caso de falhas de sanitização -- Arquitetura extensível permitindo sanitizadores personalizados -- Tratamento de erros robusto e relatórios detalhados -- Pipelines de sanitização encadeáveis para transformações complexas de dados -- Suporte nativo para múltiplas codificações de caracteres -- Proteção contra ataques XSS e injeção de SQL - -## Instalação - -Você pode instalar o componente Sanitizer via Composer: - -```bash -composer require kariricode/sanitizer -``` - -### Requisitos - -- PHP 8.3 ou superior -- Composer -- Extensões: `ext-mbstring`, `ext-dom`, `ext-libxml` - -## Uso - -### Uso Básico - -1. Defina sua classe de dados com atributos de sanitização: - -```php -use KaririCode\Sanitizer\Attribute\Sanitize; - -class UserProfile -{ - #[Sanitize(processors: ['trim', 'html_special_chars'])] - private string $name = ''; - - #[Sanitize(processors: ['trim', 'email_sanitizer'])] - private string $email = ''; - - // Getters e setters... -} -``` - -2. Configure o sanitizador e utilize-o: - -```php -use KaririCode\ProcessorPipeline\ProcessorRegistry; -use KaririCode\Sanitizer\Sanitizer; -use KaririCode\Sanitizer\Processor\Input\TrimSanitizer; -use KaririCode\Sanitizer\Processor\Input\HtmlSpecialCharsSanitizer; -use KaririCode\Sanitizer\Processor\Input\EmailSanitizer; - -$registry = new ProcessorRegistry(); -$registry->register('sanitizer', 'trim', new TrimSanitizer()); -$registry->register('sanitizer', 'html_special_chars', new HtmlSpecialCharsSanitizer()); -$registry->register('sanitizer', 'email_sanitizer', new EmailSanitizer()); - -$sanitizer = new Sanitizer($registry); - -$userProfile = new UserProfile(); -$userProfile->setName(" Walmir Silva "); -$userProfile->setEmail(" walmir.silva@gmail.con "); - -$result = $sanitizer->sanitize($userProfile); - -echo $userProfile->getName(); // Output: "Walmir Silva" -echo $userProfile->getEmail(); // Output: "walmir.silva@gmail.com" -``` - -### Uso Avançado: Sanitização de Postagem de Blog - -Aqui está um exemplo de como usar o KaririCode Sanitizer em um cenário do mundo real, como a sanitização do conteúdo de uma postagem de blog: - -```php -use KaririCode\Sanitizer\Attribute\Sanitize; - -class BlogPost -{ - #[Sanitize( - processors: ['trim', 'html_special_chars', 'xss_sanitizer'], - messages: [ - 'trim' => 'O título foi ajustado', - 'html_special_chars' => 'Caracteres especiais no título foram escapados', - 'xss_sanitizer' => 'Tentativa de XSS removida do título', - ] - )] - private string $title = ''; - - #[Sanitize( - processors: ['trim', 'markdown', 'html_purifier'], - messages: [ - 'trim' => 'O conteúdo foi ajustado', - 'markdown' => 'Markdown no conteúdo foi processado', - 'html_purifier' => 'HTML no conteúdo foi purificado', - ] - )] - private string $content = ''; - - // Getters e setters... -} - -// Exemplo de uso -$blogPost = new BlogPost(); -$blogPost->setTitle(" Explorando o KaririCode: Um Framework PHP Moderno "); -$blogPost->setContent("# Introdução -KaririCode é um framework PHP **poderoso** e _flexível_ projetado para o desenvolvimento web moderno."); - -$result = $sanitizer->sanitize($blogPost); - -// Acessar dados sanitizados -echo $blogPost->getTitle(); // Título sanitizado -echo $blogPost->getContent(); // Conteúdo sanitizado -``` - -## Sanitizadores Disponíveis - -### Sanitizadores de Entrada - -- **TrimSanitizer**: Remove espaços em branco do início e do final de uma string. - - - **Opções de Configuração**: - - `characterMask`: Especifica quais caracteres aparar. O padrão é espaço em branco. - - `trimLeft`: Booleano para aparar do lado esquerdo. O padrão é `true`. - - `trimRight`: Booleano para aparar do lado direito. O padrão é `true`. - -- **HtmlSpecialCharsSanitizer**: Converte caracteres especiais em entidades HTML para evitar ataques XSS. - - - **Opções de Configuração**: - - `flags`: Flags configuráveis como `ENT_QUOTES | ENT_HTML5`. - - `encoding`: Codificação de caracteres, por exemplo, 'UTF-8'. - - `doubleEncode`: Booleano para evitar dupla codificação. O padrão é `true`. - -- **NormalizeLineBreaksSanitizer**: Padroniza quebras de linha em diferentes sistemas operacionais. - - - **Opções de Configuração**: - - `lineEnding`: Especifica o estilo de quebra de linha. Opções: 'unix', 'windows', 'mac'. - -- **EmailSanitizer**: Valida e corrige erros comuns de digitação em e-mails, normaliza o formato do e-mail e lida com espaços em branco. - - - **Opções de Configuração**: - - `removeMailtoPrefix`: Booleano para remover o prefixo 'mailto:'. O padrão é `false`. - - `typoReplacements`: Array associativo de correções de erros de digitação comuns. - - `domainReplacements`: Corrige nomes de domínio com erros de digitação comuns. - -- **PhoneSanitizer**: Formata e valida números de telefone, incluindo suporte internacional e opções de formatação personalizada. - - - **Opções de Configuração**: - - `applyFormat`: Booleano para aplicar formatação. O padrão é `false`. - - `format`: Padrão de formatação personalizado para números de telefone. - - `placeholder`: Caractere usado como placeholder na formatação. - -- **AlphanumericSanitizer**: Remove caracteres não alfanuméricos, com opções configuráveis para permitir certos caracteres especiais. - - - **Opções de Configuração**: - - `allowSpace`, `allowUnderscore`, `allowDash`, `allowDot`: Opções booleanas para permitir caracteres específicos. - - `preserveCase`: Booleano para manter a sensibilidade a maiúsculas e minúsculas. - -- **UrlSanitizer**: Valida e normaliza URLs, garantindo o protocolo e a estrutura adequados. - - - **Opções de Configuração**: - - `enforceProtocol`: Impõe um protocolo específico, por exemplo, 'https://'. - - `defaultProtocol`: O protocolo a ser aplicado se nenhum estiver presente. - - `removeTrailingSlash`: Booleano para remover a barra final. - -- **NumericSanitizer**: Garante que a entrada seja um valor numérico, com opções para números decimais e negativos. - - - **Opções de Configuração**: - - `allowDecimal`, `allowNegative`: Opções booleanas para permitir decimais e valores negativos. - - `decimalSeparator`: Especifica o caractere usado para decimais. - -- **StripTagsSanitizer**: Remove tags HTML e PHP da entrada, com opções configuráveis para tags permitidas. - - **Opções de Configuração**: - - `allowedTags`: Lista de tags HTML a serem mantidas. - - `keepSafeAttributes`: Booleano para manter certos atributos seguros. - - `safeAttributes`: Array de atributos a serem preservados. - -### Sanitizadores de Domínio - -- **HtmlPurifierSanitizer**: Sanitiza conteúdo HTML removendo tags e atributos inseguros, garantindo uma renderização segura do HTML. - - - **Opções de Configuração**: - - `allowedTags`: Especifica quais tags são permitidas. - - `allowedAttributes`: Define atributos permitidos para cada tag. - - `removeEmptyTags`, `removeComments`: Booleano para remover tags vazias ou comentários HTML. - - `htmlEntities`: Converte caracteres em entidades HTML. O padrão é `true`. - -- **JsonSanitizer**: Valida e formata strings JSON, remove caracteres inválidos e garante a estrutura correta do JSON. - - - **Opções de Configuração**: - - `prettyPrint`: Booleano para formatar o JSON de forma legível. - - `removeInvalidCharacters`: Booleano para remover caracteres inválidos do JSON. - - `validateUnicode`: Booleano para validar caracteres Unicode. - -- **MarkdownSanitizer**: Processa e sanitiza conteúdo Markdown, escapando caracteres especiais e preservando a estrutura do Markdown. - - **Opções de Configuração**: - - `allowedElements`: Especifica elementos Markdown permitidos (por exemplo, 'p', 'h1', 'a'). - - `escapeSpecialCharacters`: Booleano para escapar caracteres especiais como '\*', '\_', etc. - - `preserveStructure`: Booleano para manter a formatação Markdown. - -### Sanitizadores de Segurança - -- **FilenameSanitizer**: Garante que nomes de arquivos sejam seguros para uso em sistemas de arquivos, removendo caracteres inseguros e validando extensões. - - - **Opções de Configuração**: - - `replacement`: Caractere usado para substituir caracteres inseguros. O padrão é `'-'`. - - `preserveExtension`: Booleano para manter a extensão do arquivo. - - `blockDangerousExtensions`: Booleano para bloquear extensões como '.exe', '.js'. - - `allowedExtensions`: Array de extensões permitidas. - -- **SqlInjectionSanitizer**: Protege contra ataques de injeção de SQL escapando caracteres especiais e removendo conteúdo potencialmente prejudicial. - - - **Opções de Configuração**: - - `escapeMap`: Array de caracteres a serem escapados. - - `removeComments`: Booleano para remover comentários SQL. - - `escapeQuotes`: Booleano para escapar aspas em consultas SQL. - -- **XssSanitizer**: Previne ataques de Cross-Site Scripting (XSS) removendo scripts maliciosos, atributos e garantindo uma saída HTML segura. - - **Opções de Configuração**: - - `removeScripts`: Booleano para remover tags `#is', '', $input); - $input = preg_replace('#(.*?)#is', '', $input); - - if ($this->hasNoAllowedTags()) { - return strip_tags($input); - } - - $dom = new \DOMDocument(); - - $dom->loadHTML( - mb_convert_encoding($input, 'HTML-ENTITIES', 'UTF-8'), - LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD - ); - - $this->sanitizeDom($dom); - - $output = $this->cleanDomOutput($dom); - - return $this->normalizeOutput($output); - } - - private function sanitizeDom(\DOMDocument $dom): void - { - $xpath = new \DOMXPath($dom); - $nodes = $xpath->query('//*'); - - if (false === $nodes) { - return; - } - - foreach ($nodes as $node) { - if (!in_array($node->nodeName, $this->allowedTags, true)) { - if ($node->parentNode) { - $textContent = $dom->createTextNode($node->textContent); - $node->parentNode->replaceChild($textContent, $node); - } - continue; - } - - $this->processNodeAttributes($node); - } - } - - private function processNodeAttributes(\DOMElement $node): void - { - if (!$this->keepSafeAttributes) { - foreach (iterator_to_array($node->attributes) as $attribute) { - $node->removeAttribute($attribute->nodeName); - } - - return; - } - - foreach (iterator_to_array($node->attributes) as $attribute) { - if (!in_array($attribute->nodeName, $this->safeAttributes, true)) { - $node->removeAttribute($attribute->nodeName); - } - } - } - - private function cleanDomOutput(\DOMDocument $dom): string - { - $output = $dom->saveHTML(); - - if (false === $output) { - return ''; - } - - $output = preg_replace( - [ - '/^\n?/', - '/<\/?html[^>]*>\n?/', - '/<\/?body[^>]*>\n?/', - ], - '', - $output - ); - - return trim($output); - } - - private function normalizeOutput(string $output): string - { - $output = preg_replace('/\n+/', '', $output); - $output = preg_replace('/>\s+<', $output); - - return trim($output); - } - - private function hasNoAllowedTags(): bool - { - return empty($this->allowedTags); - } - - private function configureAllowedTags(array $options): void - { - if (isset($options['allowedTags']) && is_array($options['allowedTags'])) { - $this->allowedTags = array_map('strtolower', $options['allowedTags']); - } - } - - private function configureSafeAttributes(array $options): void - { - $this->keepSafeAttributes = $options['keepSafeAttributes'] ?? $this->keepSafeAttributes; - - if (isset($options['safeAttributes']) && is_array($options['safeAttributes'])) { - $this->safeAttributes = array_map('strtolower', $options['safeAttributes']); - } - } -} diff --git a/src/Processor/Input/TrimSanitizer.php b/src/Processor/Input/TrimSanitizer.php deleted file mode 100644 index 37b3c8c..0000000 --- a/src/Processor/Input/TrimSanitizer.php +++ /dev/null @@ -1,66 +0,0 @@ -characterMask = $options['characterMask']; - } - - $this->trimLeft = $options['trimLeft'] ?? $this->trimLeft; - $this->trimRight = $options['trimRight'] ?? $this->trimRight; - } - - public function process(mixed $input): string - { - $input = $this->guardAgainstNonString($input); - - if (!$this->shouldPerformTrim()) { - return $input; - } - - return $this->trimSelectedSides($input); - } - - private function shouldPerformTrim(): bool - { - return $this->trimLeft || $this->trimRight; - } - - private function trimSelectedSides(string $input): string - { - if ($this->shouldTrimBothSides()) { - return trim($input, $this->characterMask); - } - - if ($this->trimLeft) { - $input = ltrim($input, $this->characterMask); - } - - if ($this->trimRight) { - $input = rtrim($input, $this->characterMask); - } - - return $input; - } - - private function shouldTrimBothSides(): bool - { - return $this->trimLeft && $this->trimRight; - } -} diff --git a/src/Processor/Input/UrlSanitizer.php b/src/Processor/Input/UrlSanitizer.php deleted file mode 100644 index a85987f..0000000 --- a/src/Processor/Input/UrlSanitizer.php +++ /dev/null @@ -1,97 +0,0 @@ -enforceProtocol = $options['enforceProtocol'] ?? $this->enforceProtocol; - $this->defaultProtocol = $options['defaultProtocol'] ?? $this->defaultProtocol; - $this->removeTrailingSlash = $options['removeTrailingSlash'] ?? $this->removeTrailingSlash; - } - - public function process(mixed $input): string - { - $input = $this->guardAgainstNonString($input); - $url = $this->trimWhitespace($input); - - if ($this->isEmpty($url)) { - return ''; - } - - return $this->buildSanitizedUrl($url); - } - - private function isEmpty(string $url): bool - { - return '' === $url; - } - - private function buildSanitizedUrl(string $url): string - { - $url = $this->normalizeSlashes($url); - $url = $this->ensureProtocol($url); - - return $this->finalizeUrl($url); - } - - private function ensureProtocol(string $url): string - { - if ($this->needsProtocol($url)) { - return $this->addDefaultProtocol($url); - } - - return $url; - } - - private function needsProtocol(string $url): bool - { - return $this->enforceProtocol && !$this->containsProtocol($url); - } - - private function containsProtocol(string $url): bool - { - $lowercaseUrl = strtolower($url); - - foreach (self::VALID_PROTOCOLS as $protocol) { - if (str_starts_with($lowercaseUrl, $protocol)) { - return true; - } - } - - return false; - } - - private function addDefaultProtocol(string $url): string - { - return $this->defaultProtocol . $url; - } - - private function finalizeUrl(string $url): string - { - if (!$this->removeTrailingSlash) { - return $url; - } - - return rtrim($url, '/'); - } -} diff --git a/src/Processor/Security/Filename/BasenameSanitizer.php b/src/Processor/Security/Filename/BasenameSanitizer.php deleted file mode 100644 index d59d6c2..0000000 --- a/src/Processor/Security/Filename/BasenameSanitizer.php +++ /dev/null @@ -1,87 +0,0 @@ -sanitizePreservingDots($basename); - } - - return $this->sanitizeWithoutDots($basename); - } - - private function sanitizePreservingDots(string $basename): string - { - $parts = explode('.', $basename); - $sanitizedParts = array_map( - fn (string $part) => $this->sanitizePart($part), - $parts - ); - - $result = implode('.', $sanitizedParts); - - return $this->finalizeBasename($result); - } - - private function sanitizeWithoutDots(string $basename): string - { - $sanitized = preg_replace('/[^\p{L}\p{N}_\-\s]/u', $this->replacement, $basename); - $sanitized = str_replace([' ', '.', '-'], $this->replacement, $sanitized); - $sanitized = $this->normalizeReplacement($sanitized); - - return $this->finalizeBasename($sanitized); - } - - private function sanitizePart(string $part): string - { - $sanitized = preg_replace('/[^\p{L}\p{N}_\-\s]/u', $this->replacement, $part); - $sanitized = str_replace([' ', '-'], $this->replacement, $sanitized); - - return $this->normalizeReplacement($sanitized); - } - - private function normalizeReplacement(string $input): string - { - $normalized = preg_replace( - '/' . preg_quote($this->replacement, '/') . '{2,}/', - $this->replacement, - $input - ); - - return trim($normalized, $this->replacement); - } - - private function finalizeBasename(string $basename): string - { - if ($this->toLowerCase) { - $basename = strtolower($basename); - } - - return $this->truncateBasename($basename); - } - - private function truncateBasename(string $basename): string - { - if (strlen($basename) <= $this->maxLength) { - return $basename; - } - - if (false !== ($lastSpace = strrpos(substr($basename, 0, $this->maxLength), ' '))) { - return substr($basename, 0, $lastSpace); - } - - return substr($basename, 0, $this->maxLength); - } -} diff --git a/src/Processor/Security/Filename/Configuration.php b/src/Processor/Security/Filename/Configuration.php deleted file mode 100644 index fe5c3a4..0000000 --- a/src/Processor/Security/Filename/Configuration.php +++ /dev/null @@ -1,90 +0,0 @@ -configureBasicOptions($options); - $this->configureExtensionOptions($options); - $this->configureSecurityOptions($options); - } - - private function configureBasicOptions(array $options): void - { - if (isset($options['replacement']) && $this->isValidReplacement($options['replacement'])) { - $this->replacement = $options['replacement']; - } - - if (isset($options['preserveExtension'])) { - $this->preserveExtension = (bool) $options['preserveExtension']; - } - - if (isset($options['maxLength']) && $options['maxLength'] > 0) { - $this->maxLength = (int) $options['maxLength']; - } - - if (isset($options['toLowerCase'])) { - $this->toLowerCase = (bool) $options['toLowerCase']; - } - } - - private function configureExtensionOptions(array $options): void - { - if (isset($options['allowedExtensions']) && is_array($options['allowedExtensions'])) { - $this->allowedExtensions = array_map('strtolower', $options['allowedExtensions']); - } - } - - private function configureSecurityOptions(array $options): void - { - if (isset($options['blockDangerousExtensions'])) { - $this->blockDangerousExtensions = (bool) $options['blockDangerousExtensions']; - } - } - - private function isValidReplacement(string $replacement): bool - { - return 1 === strlen($replacement) && 1 === preg_match('/^[\w\-]$/', $replacement); - } - - public function getReplacement(): string - { - return $this->replacement; - } - - public function isPreserveExtension(): bool - { - return $this->preserveExtension; - } - - public function getMaxLength(): int - { - return $this->maxLength; - } - - public function isToLowerCase(): bool - { - return $this->toLowerCase; - } - - public function getAllowedExtensions(): array - { - return $this->allowedExtensions; - } - - public function isBlockDangerousExtensions(): bool - { - return $this->blockDangerousExtensions; - } -} diff --git a/src/Processor/Security/Filename/ExtensionHandler.php b/src/Processor/Security/Filename/ExtensionHandler.php deleted file mode 100644 index e9848ea..0000000 --- a/src/Processor/Security/Filename/ExtensionHandler.php +++ /dev/null @@ -1,81 +0,0 @@ -blockDangerousExtensions = $blockDangerousExtensions; - $this->allowedExtensions = array_map('strtolower', $allowedExtensions); - } - - public function sanitizeExtension(string $extension): string - { - if ('' === $extension) { - return ''; - } - - $extension = strtolower($extension); - $extension = ltrim($extension, '.'); - - $parts = explode('.', $extension); - $lastPart = end($parts); - - if ($this->isDangerousExtension($lastPart)) { - return ''; - } - - if ($this->hasAllowedExtensionsRestriction() && !$this->isAllowedExtension($lastPart)) { - return ''; - } - - return '.' . $extension; - } - - public function isCompoundExtension(string $filename): bool - { - foreach (self::COMPOUND_EXTENSIONS as $ext) { - if (str_ends_with(strtolower($filename), $ext)) { - return true; - } - } - - return false; - } - - private function isDangerousExtension(string $extension): bool - { - if (!$this->blockDangerousExtensions) { - return false; - } - - return in_array(strtolower($extension), self::DANGEROUS_EXTENSIONS, true); - } - - private function hasAllowedExtensionsRestriction(): bool - { - return !empty($this->allowedExtensions); - } - - private function isAllowedExtension(string $extension): bool - { - return in_array($extension, $this->allowedExtensions, true); - } -} diff --git a/src/Processor/Security/Filename/FilenameParser.php b/src/Processor/Security/Filename/FilenameParser.php deleted file mode 100644 index 82dcf5e..0000000 --- a/src/Processor/Security/Filename/FilenameParser.php +++ /dev/null @@ -1,43 +0,0 @@ -shouldHandleCompoundExtension($filename, $basename)) { - $previousDotPosition = strrpos($basename, '.'); - $basename = substr($filename, 0, $previousDotPosition); - $extension = substr($filename, $previousDotPosition); - } - - return [$basename, $extension]; - } - - private function shouldHandleCompoundExtension(string $filename, string $basename): bool - { - $previousDotPosition = strrpos($basename, '.'); - - return false !== $previousDotPosition && $this->extensionHandler->isCompoundExtension($filename); - } -} diff --git a/src/Processor/Security/FilenameSanitizer.php b/src/Processor/Security/FilenameSanitizer.php deleted file mode 100644 index 42a3320..0000000 --- a/src/Processor/Security/FilenameSanitizer.php +++ /dev/null @@ -1,96 +0,0 @@ -config = new Configuration(); - $this->initializeDependencies(); - } - - public function configure(array $options): void - { - $this->config->configure($options); - $this->initializeDependencies(); - } - - public function process(mixed $input): string - { - $input = $this->guardAgainstNonString($input); - $input = $this->trimWhitespace($input); - - if ('' === $input) { - return ''; - } - - [$basename, $extension] = $this->filenameParser->splitFilename( - $input, - $this->config->isPreserveExtension() - ); - - $sanitizedBasename = $this->basenameSanitizer->sanitize( - $basename, - $this->config->isPreserveExtension() - ); - - if ('' === $sanitizedBasename) { - return ''; - } - - $sanitizedExtension = $extension ? $this->extensionHandler->sanitizeExtension($extension) : ''; - - return $this->buildFinalFilename($sanitizedBasename, $sanitizedExtension); - } - - private function initializeDependencies(): void - { - $this->extensionHandler = new ExtensionHandler( - $this->config->isBlockDangerousExtensions(), - $this->config->getAllowedExtensions() - ); - - $this->filenameParser = new FilenameParser($this->extensionHandler); - - $this->basenameSanitizer = new BasenameSanitizer( - $this->config->getReplacement(), - $this->config->isToLowerCase(), - $this->config->getMaxLength() - ); - } - - private function buildFinalFilename(string $basename, string $extension): string - { - $filename = $basename . $extension; - - if (strlen($filename) > $this->config->getMaxLength()) { - $maxBasenameLength = $this->config->getMaxLength() - strlen($extension); - if ($maxBasenameLength > 0) { - $basename = substr($basename, 0, $maxBasenameLength); - $filename = $basename . $extension; - } else { - $filename = substr($filename, 0, $this->config->getMaxLength()); - } - } - - return $filename; - } -} diff --git a/src/Processor/Security/SqlInjectionSanitizer.php b/src/Processor/Security/SqlInjectionSanitizer.php deleted file mode 100644 index 77f4753..0000000 --- a/src/Processor/Security/SqlInjectionSanitizer.php +++ /dev/null @@ -1,54 +0,0 @@ - '', // Remove single-line comments - '/\/\*.*?\*\//s' => '', // Remove multi-line comments - '/;/' => '', // Remove semicolons completamente - '/\s+/' => ' ', // Normalize whitespace - ]; - - private array $escapeMap = [ - "\x00" => '\\0', - "\n" => '\\n', - "\r" => '\\r', - '\\' => '\\\\', - "'" => "\\'", - '"' => '\\"', - "\x1a" => '\\Z', - ]; - - public function configure(array $options): void - { - if (isset($options['escapeMap']) && is_array($options['escapeMap'])) { - $this->escapeMap = array_merge($this->escapeMap, $options['escapeMap']); - } - } - - public function process(mixed $input): string - { - $input = $this->guardAgainstNonString($input); - $input = $this->removeSuspiciousPatterns($input); - - return $this->escapeString($input); - } - - private function removeSuspiciousPatterns(string $input): string - { - return preg_replace(array_keys(self::SUSPICIOUS_PATTERNS), array_values(self::SUSPICIOUS_PATTERNS), $input); - } - - private function escapeString(string $input): string - { - return strtr($input, $this->escapeMap); - } -} diff --git a/src/Processor/Security/XssSanitizer.php b/src/Processor/Security/XssSanitizer.php deleted file mode 100644 index 6c815dc..0000000 --- a/src/Processor/Security/XssSanitizer.php +++ /dev/null @@ -1,17 +0,0 @@ -guardAgainstNonString($input); - - return htmlspecialchars($input, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - } -} diff --git a/src/Provider/SanitizerServiceProvider.php b/src/Provider/SanitizerServiceProvider.php new file mode 100644 index 0000000..b6f726e --- /dev/null +++ b/src/Provider/SanitizerServiceProvider.php @@ -0,0 +1,106 @@ + + * + * @since 3.1.0 ARFA 1.3 + */ +final class SanitizerServiceProvider +{ + public function createRegistry(): InMemoryRuleRegistry + { + $registry = new InMemoryRuleRegistry(); + $this->registerBuiltinRules($registry); + + return $registry; + } + + public function createEngine(?SanitizerConfiguration $configuration = null): SanitizerEngine + { + return new SanitizerEngine($this->createRegistry(), $configuration); + } + + public function createAttributeSanitizer(?SanitizerConfiguration $configuration = null): AttributeSanitizer + { + return new AttributeSanitizer($this->createEngine($configuration)); + } + + private function registerBuiltinRules(InMemoryRuleRegistry $registry): void + { + // ── String (12) ─────────────────────────────────────────── + $registry->register('trim', new TrimRule()); + $registry->register('lower_case', new LowerCaseRule()); + $registry->register('upper_case', new UpperCaseRule()); + $registry->register('capitalize', new CapitalizeRule()); + $registry->register('slug', new SlugRule()); + $registry->register('truncate', new TruncateRule()); + $registry->register('normalize_whitespace', new NormalizeWhitespaceRule()); + $registry->register('normalize_line_endings', new NormalizeLineEndingsRule()); + $registry->register('pad', new PadRule()); + $registry->register('replace', new ReplaceRule()); + $registry->register('regex_replace', new RegexReplaceRule()); + $registry->register('strip_non_printable', new StripNonPrintableRule()); + + // ── Html (5) ────────────────────────────────────────────── + $registry->register('strip_tags', new Html\StripTagsRule()); + $registry->register('html_encode', new Html\HtmlEncodeRule()); + $registry->register('html_decode', new Html\HtmlDecodeRule()); + $registry->register('html_purify', new Html\HtmlPurifyRule()); + $registry->register('url_encode', new Html\UrlEncodeRule()); + + // ── Numeric (4) ─────────────────────────────────────────── + $registry->register('to_int', new Numeric\ToIntRule()); + $registry->register('to_float', new Numeric\ToFloatRule()); + $registry->register('clamp', new Numeric\ClampRule()); + $registry->register('round', new Numeric\RoundRule()); + + // ── Type (3) ────────────────────────────────────────────── + $registry->register('to_bool', new Type\ToBoolRule()); + $registry->register('to_string', new Type\ToStringRule()); + $registry->register('to_array', new Type\ToArrayRule()); + + // ── Date (2) ────────────────────────────────────────────── + $registry->register('normalize_date', new Date\NormalizeDateRule()); + $registry->register('timestamp_to_date', new Date\TimestampToDateRule()); + + // ── Filter (4) ──────────────────────────────────────────── + $registry->register('digits_only', new Filter\DigitsOnlyRule()); + $registry->register('alpha_only', new Filter\AlphaOnlyRule()); + $registry->register('alphanumeric_only', new Filter\AlphanumericOnlyRule()); + $registry->register('email_filter', new Filter\EmailFilterRule()); + + // ── Brazilian (3) ───────────────────────────────────────── + $registry->register('format_cpf', new Brazilian\FormatCpfRule()); + $registry->register('format_cnpj', new Brazilian\FormatCnpjRule()); + $registry->register('format_cep', new Brazilian\FormatCepRule()); + } +} diff --git a/src/Result/FieldModification.php b/src/Result/FieldModification.php new file mode 100644 index 0000000..a916d1b --- /dev/null +++ b/src/Result/FieldModification.php @@ -0,0 +1,28 @@ + + * + * @since 3.1.0 ARFA 1.3 + */ +final readonly class FieldModification +{ + public function __construct( + public string $field, + public string $ruleName, + public mixed $before, + public mixed $after, + ) { + } + + public function wasModified(): bool + { + return $this->before !== $this->after; + } +} diff --git a/src/Result/SanitizationResult.php b/src/Result/SanitizationResult.php index 0cdb39d..1cc8c2b 100644 --- a/src/Result/SanitizationResult.php +++ b/src/Result/SanitizationResult.php @@ -4,33 +4,116 @@ namespace KaririCode\Sanitizer\Result; -use KaririCode\ProcessorPipeline\Result\ProcessingResultCollection; -use KaririCode\Sanitizer\Contract\SanitizationResult as SanitizationResultcontract; - -final class SanitizationResult implements SanitizationResultcontract +/** + * Result of a sanitization pass — contains sanitized data and modification log. + * + * @author Walmir Silva + * + * @since 3.1.0 ARFA 1.3 + */ +final class SanitizationResult { + /** @var list */ + private array $modifications = []; + + /** + * @param array $originalData + * @param array $sanitizedData + */ public function __construct( - private readonly ProcessingResultCollection $results + private readonly array $originalData, + private array $sanitizedData, ) { } - public function isValid(): bool + /** @return array */ + public function getOriginalData(): array { - return !$this->results->hasErrors(); + return $this->originalData; } - public function getErrors(): array + /** @return array */ + public function getSanitizedData(): array { - return $this->results->getErrors(); + return $this->sanitizedData; } - public function getSanitizedData(): array + public function get(string $field): mixed + { + return $this->sanitizedData[$field] ?? null; + } + + public function wasModified(): bool + { + return $this->originalData !== $this->sanitizedData; + } + + public function isFieldModified(string $field): bool + { + if (! \array_key_exists($field, $this->originalData)) { + return \array_key_exists($field, $this->sanitizedData); + } + + return ($this->originalData[$field] ?? null) !== ($this->sanitizedData[$field] ?? null); + } + + /** @return list */ + public function modifiedFields(): array + { + $fields = []; + foreach ($this->sanitizedData as $field => $value) { + if ($this->isFieldModified($field)) { + $fields[] = $field; + } + } + + return $fields; + } + + public function addModification(FieldModification $modification): void + { + $this->modifications[] = $modification; + } + + public function setSanitizedValue(string $field, mixed $value): void + { + $this->sanitizedData[$field] = $value; + } + + /** @return list */ + public function getModifications(): array { - return $this->results->getProcessedData(); + return $this->modifications; } - public function toArray(): array + /** @return list */ + public function modificationsFor(string $field): array { - return $this->results->toArray(); + return array_values(array_filter( + $this->modifications, + static fn (FieldModification $m): bool => $m->field === $field, + )); + } + + public function modificationCount(): int + { + return \count(array_filter( + $this->modifications, + static fn (FieldModification $m): bool => $m->wasModified(), + )); + } + + public function merge(self $other): self + { + $merged = new self( + [...$this->originalData, ...$other->originalData], + [...$this->sanitizedData, ...$other->sanitizedData], + ); + + foreach ([...$this->modifications, ...$other->modifications] as $mod) { + $merged->addModification($mod); + } + + return $merged; } } diff --git a/src/Rule/Brazilian/FormatCepRule.php b/src/Rule/Brazilian/FormatCepRule.php new file mode 100644 index 0000000..993f024 --- /dev/null +++ b/src/Rule/Brazilian/FormatCepRule.php @@ -0,0 +1,36 @@ +getParameter('from', 'd/m/Y'); + $from = \is_string($fromRaw) ? $fromRaw : 'd/m/Y'; + $toRaw = $context->getParameter('to', 'Y-m-d'); + $to = \is_string($toRaw) ? $toRaw : 'Y-m-d'; + + $date = \DateTimeImmutable::createFromFormat($from, $value); + + if (false === $date) { + return $value; + } + + return $date->format($to); + } + + #[\Override] + public function getName(): string + { + return 'date.normalize'; + } +} diff --git a/src/Rule/Date/TimestampToDateRule.php b/src/Rule/Date/TimestampToDateRule.php new file mode 100644 index 0000000..893fa82 --- /dev/null +++ b/src/Rule/Date/TimestampToDateRule.php @@ -0,0 +1,44 @@ +getParameter('format', 'Y-m-d H:i:s'); + $format = \is_string($formatRaw) ? $formatRaw : 'Y-m-d H:i:s'; + $timezoneRaw = $context->getParameter('timezone', 'UTC'); + $timezone = (\is_string($timezoneRaw) && '' !== $timezoneRaw) ? $timezoneRaw : 'UTC'; + + try { + $dt = new \DateTimeImmutable('@' . (int) $value) + ->setTimezone(new \DateTimeZone($timezone)); + + return $dt->format($format); + } catch (\Exception) { + return $value; + } + } + + #[\Override] + public function getName(): string + { + return 'date.timestamp_to_date'; + } +} diff --git a/src/Rule/Filter/AlphaOnlyRule.php b/src/Rule/Filter/AlphaOnlyRule.php new file mode 100644 index 0000000..2d62368 --- /dev/null +++ b/src/Rule/Filter/AlphaOnlyRule.php @@ -0,0 +1,30 @@ +getParameter('flags', ENT_QUOTES | ENT_SUBSTITUTE); + $flags = \is_int($flagsRaw) ? $flagsRaw : ENT_QUOTES | ENT_SUBSTITUTE; + $encodingRaw = $context->getParameter('encoding', 'UTF-8'); + $encoding = \is_string($encodingRaw) ? $encodingRaw : 'UTF-8'; + + return html_entity_decode($value, $flags, $encoding); + } + + #[\Override] + public function getName(): string + { + return 'html.decode'; + } +} diff --git a/src/Rule/Html/HtmlEncodeRule.php b/src/Rule/Html/HtmlEncodeRule.php new file mode 100644 index 0000000..ed27d85 --- /dev/null +++ b/src/Rule/Html/HtmlEncodeRule.php @@ -0,0 +1,39 @@ +getParameter('flags', ENT_QUOTES | ENT_SUBSTITUTE); + $flags = \is_int($flagsRaw) ? $flagsRaw : ENT_QUOTES | ENT_SUBSTITUTE; + $encodingRaw = $context->getParameter('encoding', 'UTF-8'); + $encoding = \is_string($encodingRaw) ? $encodingRaw : 'UTF-8'; + $doubleEncodeRaw = $context->getParameter('double_encode', true); + $doubleEncode = \is_bool($doubleEncodeRaw) ? $doubleEncodeRaw : (bool) $doubleEncodeRaw; + + return htmlspecialchars($value, $flags, $encoding, $doubleEncode); + } + + #[\Override] + public function getName(): string + { + return 'html.encode'; + } +} diff --git a/src/Rule/Html/HtmlPurifyRule.php b/src/Rule/Html/HtmlPurifyRule.php new file mode 100644 index 0000000..4f476a4 --- /dev/null +++ b/src/Rule/Html/HtmlPurifyRule.php @@ -0,0 +1,38 @@ +getParameter('allowed', ''); + $allowed = \is_string($allowedRaw) ? $allowedRaw : ''; + $value = strip_tags($value, '' !== $allowed ? $allowed : null); + $value = html_entity_decode($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return trim($value); + } + + #[\Override] + public function getName(): string + { + return 'html.purify'; + } +} diff --git a/src/Rule/Html/StripTagsRule.php b/src/Rule/Html/StripTagsRule.php new file mode 100644 index 0000000..c804500 --- /dev/null +++ b/src/Rule/Html/StripTagsRule.php @@ -0,0 +1,35 @@ +'). + */ +final readonly class StripTagsRule implements SanitizationRule +{ + #[\Override] + public function sanitize(mixed $value, SanitizationContext $context): mixed + { + if (! \is_string($value)) { + return $value; + } + + $allowedRaw = $context->getParameter('allowed', ''); + $allowed = \is_string($allowedRaw) ? $allowedRaw : ''; + + return strip_tags($value, '' !== $allowed ? $allowed : null); + } + + #[\Override] + public function getName(): string + { + return 'html.strip_tags'; + } +} diff --git a/src/Rule/Html/UrlEncodeRule.php b/src/Rule/Html/UrlEncodeRule.php new file mode 100644 index 0000000..ebc225c --- /dev/null +++ b/src/Rule/Html/UrlEncodeRule.php @@ -0,0 +1,35 @@ +getParameter('raw', false); + $raw = \is_bool($rawParam) ? $rawParam : (bool) $rawParam; + + return $raw ? rawurlencode($value) : urlencode($value); + } + + #[\Override] + public function getName(): string + { + return 'html.url_encode'; + } +} diff --git a/src/Rule/Numeric/ClampRule.php b/src/Rule/Numeric/ClampRule.php new file mode 100644 index 0000000..728645b --- /dev/null +++ b/src/Rule/Numeric/ClampRule.php @@ -0,0 +1,44 @@ +getParameter('min'); + $max = $context->getParameter('max'); + + if (null !== $min && is_numeric($min) && $numeric < $min) { + return \is_int($value) ? (int) $min : (float) $min; + } + + if (null !== $max && is_numeric($max) && $numeric > $max) { + return \is_int($value) ? (int) $max : (float) $max; + } + + return $value; + } + + #[\Override] + public function getName(): string + { + return 'numeric.clamp'; + } +} diff --git a/src/Rule/Numeric/RoundRule.php b/src/Rule/Numeric/RoundRule.php new file mode 100644 index 0000000..ebfb2ce --- /dev/null +++ b/src/Rule/Numeric/RoundRule.php @@ -0,0 +1,43 @@ +getParameter('precision', 2); + $precision = \is_int($precisionRaw) ? $precisionRaw : 2; + $modeRaw = $context->getParameter('mode', 'round'); + $mode = \is_string($modeRaw) ? $modeRaw : 'round'; + $numeric = (float) $value; + $multiplier = 10 ** $precision; + + return match ($mode) { + 'ceil' => ceil($numeric * (float) $multiplier) / (float) $multiplier, + 'floor' => floor($numeric * (float) $multiplier) / (float) $multiplier, + default => round($numeric, $precision), + }; + } + + #[\Override] + public function getName(): string + { + return 'numeric.round'; + } +} diff --git a/src/Rule/Numeric/ToFloatRule.php b/src/Rule/Numeric/ToFloatRule.php new file mode 100644 index 0000000..c579024 --- /dev/null +++ b/src/Rule/Numeric/ToFloatRule.php @@ -0,0 +1,34 @@ +getParameter('length', 0); + $length = \is_int($lengthRaw) ? $lengthRaw : 0; + $padRaw = $context->getParameter('pad', ' '); + $pad = \is_string($padRaw) ? $padRaw : ' '; + $sideRaw = $context->getParameter('side', 'right'); + $side = \is_string($sideRaw) ? $sideRaw : 'right'; + + $padType = match ($side) { + 'left' => STR_PAD_LEFT, + 'both' => STR_PAD_BOTH, + default => STR_PAD_RIGHT, + }; + + return str_pad($value, $length, $pad, $padType); + } + + #[\Override] + public function getName(): string + { + return 'string.pad'; + } +} diff --git a/src/Rule/String/RegexReplaceRule.php b/src/Rule/String/RegexReplaceRule.php new file mode 100644 index 0000000..b34817e --- /dev/null +++ b/src/Rule/String/RegexReplaceRule.php @@ -0,0 +1,41 @@ +getParameter('pattern', ''); + $pattern = \is_string($patternRaw) ? $patternRaw : ''; + $replacementRaw = $context->getParameter('replacement', ''); + $replacement = \is_string($replacementRaw) ? $replacementRaw : ''; + + if ('' === $pattern) { + return $value; + } + + return preg_replace($pattern, $replacement, $value) ?? $value; + } + + #[\Override] + public function getName(): string + { + return 'string.regex_replace'; + } +} diff --git a/src/Rule/String/ReplaceRule.php b/src/Rule/String/ReplaceRule.php new file mode 100644 index 0000000..af5b3f8 --- /dev/null +++ b/src/Rule/String/ReplaceRule.php @@ -0,0 +1,40 @@ +getParameter('search', ''); + if (! \is_string($searchRaw) || '' === $searchRaw) { + return $value; + } + + $replaceRaw = $context->getParameter('replace', ''); + $replace = \is_string($replaceRaw) ? $replaceRaw : ''; + + return str_replace($searchRaw, $replace, $value); + } + + #[\Override] + public function getName(): string + { + return 'string.replace'; + } +} diff --git a/src/Rule/String/SlugRule.php b/src/Rule/String/SlugRule.php new file mode 100644 index 0000000..06db1ed --- /dev/null +++ b/src/Rule/String/SlugRule.php @@ -0,0 +1,62 @@ +getParameter('separator', '-'); + $separator = \is_string($separatorRaw) ? $separatorRaw : '-'; + + // Transliterate common accented characters + $slug = $this->transliterate($value); + $slug = mb_strtolower($slug, 'UTF-8'); + $slug = preg_replace('/[^a-z0-9]+/', $separator, $slug) ?? $slug; + $slug = trim($slug, $separator); + + return $slug; + } + + #[\Override] + public function getName(): string + { + return 'string.slug'; + } + + private function transliterate(string $value): string + { + $map = [ + 'á' => 'a', 'à' => 'a', 'ã' => 'a', 'â' => 'a', 'ä' => 'a', + 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e', + 'í' => 'i', 'ì' => 'i', 'î' => 'i', 'ï' => 'i', + 'ó' => 'o', 'ò' => 'o', 'õ' => 'o', 'ô' => 'o', 'ö' => 'o', + 'ú' => 'u', 'ù' => 'u', 'û' => 'u', 'ü' => 'u', + 'ç' => 'c', 'ñ' => 'n', 'ß' => 'ss', + 'Á' => 'A', 'À' => 'A', 'Ã' => 'A', 'Â' => 'A', 'Ä' => 'A', + 'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E', + 'Í' => 'I', 'Ì' => 'I', 'Î' => 'I', 'Ï' => 'I', + 'Ó' => 'O', 'Ò' => 'O', 'Õ' => 'O', 'Ô' => 'O', 'Ö' => 'O', + 'Ú' => 'U', 'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U', + 'Ç' => 'C', 'Ñ' => 'N', + ]; + + return strtr($value, $map); + } +} diff --git a/src/Rule/String/StripNonPrintableRule.php b/src/Rule/String/StripNonPrintableRule.php new file mode 100644 index 0000000..313ffc5 --- /dev/null +++ b/src/Rule/String/StripNonPrintableRule.php @@ -0,0 +1,31 @@ +getParameter('characters', " \t\n\r\0\x0B"); + $characters = \is_string($characters) ? $characters : " \t\n\r\0\x0B"; + + return trim($value, $characters); + } + + #[\Override] + public function getName(): string + { + return 'string.trim'; + } +} diff --git a/src/Rule/String/TruncateRule.php b/src/Rule/String/TruncateRule.php new file mode 100644 index 0000000..dd0406c --- /dev/null +++ b/src/Rule/String/TruncateRule.php @@ -0,0 +1,41 @@ +getParameter('max', 255); + $max = \is_int($maxRaw) ? $maxRaw : 255; + $suffixRaw = $context->getParameter('suffix', '...'); + $suffix = \is_string($suffixRaw) ? $suffixRaw : '...'; + + if (mb_strlen($value, 'UTF-8') <= $max) { + return $value; + } + + return mb_substr($value, 0, $max - mb_strlen($suffix, 'UTF-8'), 'UTF-8') . $suffix; + } + + #[\Override] + public function getName(): string + { + return 'string.truncate'; + } +} diff --git a/src/Rule/String/UpperCaseRule.php b/src/Rule/String/UpperCaseRule.php new file mode 100644 index 0000000..a0f3ac0 --- /dev/null +++ b/src/Rule/String/UpperCaseRule.php @@ -0,0 +1,23 @@ + true, + '0', 'false', 'no', 'off', '' => false, + default => $value, + }; + } + + if (is_numeric($value)) { + return (bool) $value; + } + + return $value; + } + + #[\Override] + public function getName(): string + { + return 'type.to_bool'; + } +} diff --git a/src/Rule/Type/ToStringRule.php b/src/Rule/Type/ToStringRule.php new file mode 100644 index 0000000..e4cef2b --- /dev/null +++ b/src/Rule/Type/ToStringRule.php @@ -0,0 +1,34 @@ +builder = new ProcessorBuilder($this->registry); - } - - public function sanitize(mixed $object): SanitizationResultContract - { - $attributeHandler = new ProcessorAttributeHandler( - self::IDENTIFIER, - $this->builder - ); - - $propertyInspector = new PropertyInspector( - new AttributeAnalyzer(Sanitize::class) - ); - - /** @var PropertyAttributeHandler */ - $handler = $propertyInspector->inspect($object, $attributeHandler); - $attributeHandler->applyChanges($object); - - return new SanitizationResult( - $handler->getProcessingResults() - ); - } -} diff --git a/src/Trait/CaseTransformerTrait.php b/src/Trait/CaseTransformerTrait.php deleted file mode 100644 index c8dacac..0000000 --- a/src/Trait/CaseTransformerTrait.php +++ /dev/null @@ -1,49 +0,0 @@ -isAlreadyCamelCase($input)) { - return $input; - } - - // Remove extra underscores and normalize - $input = trim($input, '_'); - $input = preg_replace('/_+/', '_', $input); - - // Convert to camelCase - $input = strtolower($input); - $output = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $input)))); - - return $output; - } - - private function isAlreadyCamelCase(string $input): bool - { - return - // Starts with a lowercase letter - preg_match('/^[a-z]/', $input) - // Contains at least one uppercase letter after the first position - && preg_match('/[A-Z]/', substr($input, 1)) - // Does not contain underscores - && !str_contains($input, '_') - // Follows camelCase pattern (lowercase letter followed by uppercase) - && preg_match('/^[a-z]+(?:[A-Z][a-z0-9]+)*$/', $input); - } -} diff --git a/src/Trait/CharacterFilterTrait.php b/src/Trait/CharacterFilterTrait.php deleted file mode 100644 index 0ca8955..0000000 --- a/src/Trait/CharacterFilterTrait.php +++ /dev/null @@ -1,48 +0,0 @@ - preg_quote($char, '/'), - $additionalChars - ); - $allowed .= implode('', $escaped); - } - - return preg_replace('/[^' . $allowed . ']/u', '', $input); - } -} diff --git a/src/Trait/CharacterReplacementTrait.php b/src/Trait/CharacterReplacementTrait.php deleted file mode 100644 index 4ddb9d8..0000000 --- a/src/Trait/CharacterReplacementTrait.php +++ /dev/null @@ -1,18 +0,0 @@ -substituteEntities = false; - $dom->formatOutput = false; - - if ($wrapInRoot) { - $safeInput = '
' . htmlspecialchars_decode($input, ENT_QUOTES | ENT_HTML5) . '
'; - } else { - $safeInput = htmlspecialchars_decode($input, ENT_QUOTES | ENT_HTML5); - } - - $dom->loadHTML( - mb_convert_encoding($safeInput, 'HTML-ENTITIES', 'UTF-8'), - LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD - ); - - return $dom; - } - - protected function cleanDomOutput(\DOMDocument $dom): string - { - // Save without XML declaration and DOCTYPE - $output = $dom->saveHTML(); - - if (false === $output) { - return ''; - } - - // Remove DOCTYPE, html and body tags - $output = preg_replace( - [ - '/^\n?/', - '/<\/?html[^>]*>\n?/', - '/<\/?body[^>]*>\n?/', - ], - '', - $output - ); - - return trim($output); - } -} diff --git a/src/Trait/HtmlCleanerTrait.php b/src/Trait/HtmlCleanerTrait.php deleted file mode 100644 index d90835c..0000000 --- a/src/Trait/HtmlCleanerTrait.php +++ /dev/null @@ -1,31 +0,0 @@ -]*>(.*?)<\/script>/is', '', $input); - $input = preg_replace('/\bon\w+\s*=\s*"[^"]*"/i', '', $input); - - return $input; - } - - protected function removeComments(string $input): string - { - // Regular expression that matches the innermost HTML comments first - while (preg_match('//', $input)) { - $input = preg_replace('//', '', $input); - } - - return $input; - } - - protected function removeStyle(string $input): string - { - return preg_replace('#(.*?)#is', '', $input); - } -} diff --git a/src/Trait/NumericSanitizerTrait.php b/src/Trait/NumericSanitizerTrait.php deleted file mode 100644 index 8179beb..0000000 --- a/src/Trait/NumericSanitizerTrait.php +++ /dev/null @@ -1,23 +0,0 @@ - 2) { - return $this->extractNumbers($parts[0]) . $decimalPoint . $this->extractNumbers(implode('', array_slice($parts, 1))); - } - - return implode($decimalPoint, array_map([$this, 'extractNumbers'], $parts)); - } -} diff --git a/src/Trait/UrlSanitizerTrait.php b/src/Trait/UrlSanitizerTrait.php deleted file mode 100644 index f02ba13..0000000 --- a/src/Trait/UrlSanitizerTrait.php +++ /dev/null @@ -1,94 +0,0 @@ -hasValidProtocol($url); - - if ($hasValidProtocol) { - return $url; - } - - return $defaultProtocol . ltrim($url, '/'); - } - - protected function normalizeSlashes(string $url): string - { - if (empty($url)) { - return ''; - } - - $protocol = $this->extractProtocol($url); - $path = $this->extractPath($url, $protocol); - $normalizedPath = $this->normalizePath($path); - - if ($this->isPathEmpty($normalizedPath)) { - return '/'; - } - - return $this->buildUrl($protocol, $normalizedPath); - } - - private function hasValidProtocol(string $url): bool - { - $lowercaseUrl = strtolower($url); - - foreach (self::VALID_PROTOCOLS as $protocol) { - if (str_starts_with($lowercaseUrl, $protocol)) { - return true; - } - } - - return false; - } - - private function extractProtocol(string $url): string - { - $matches = []; - preg_match('/^[a-zA-Z]+:/', $url, $matches); - - return $matches[0] ?? ''; - } - - private function extractPath(string $url, string $protocol): string - { - if (empty($protocol)) { - return $url; - } - - $parts = explode($protocol, $url, 2); - - return $parts[1] ?? ''; - } - - private function normalizePath(string $path): string - { - return preg_replace('/\/+/', '/', $path); - } - - private function isPathEmpty(string $path): bool - { - return '' === trim($path, '/'); - } - - private function buildUrl(string $protocol, string $path): string - { - if (empty($protocol)) { - return $path; - } - - return $protocol . '//' . ltrim($path, '/'); - } -} diff --git a/src/Trait/ValidationTrait.php b/src/Trait/ValidationTrait.php deleted file mode 100644 index 3495be5..0000000 --- a/src/Trait/ValidationTrait.php +++ /dev/null @@ -1,23 +0,0 @@ -assertInstanceOf(ProcessableAttribute::class, $sanitize); - } - - public function testSanitizeImplementsCustomizableMessageAttribute(): void - { - $sanitize = new Sanitize([]); - $this->assertInstanceOf(CustomizableMessageAttribute::class, $sanitize); - } - - public function testSanitizeIsAttribute(): void - { - $reflectionClass = new \ReflectionClass(Sanitize::class); - $attributes = $reflectionClass->getAttributes(); - - $this->assertCount(1, $attributes); - $this->assertSame(\Attribute::class, $attributes[0]->getName()); - $this->assertSame([\Attribute::TARGET_PROPERTY], $attributes[0]->getArguments()); - } - - public function testConstructorFiltersInvalidProcessors(): void - { - $processors = ['trim', null, false, 'htmlspecialchars']; - $expectedProcessors = ['trim', 'htmlspecialchars']; - $sanitize = new Sanitize($processors); - - $this->assertSame(array_values($expectedProcessors), array_values($sanitize->getProcessors())); - } - - public function testGetProcessorsReturnsProcessors(): void - { - $processors = ['trim', 'htmlspecialchars']; - $sanitize = new Sanitize($processors); - - $this->assertSame($processors, $sanitize->getProcessors()); - } - - public function testGetMessageReturnsNullWhenNoMessagesProvided(): void - { - $sanitize = new Sanitize(['trim']); - $this->assertNull($sanitize->getMessage('trim')); - } - - public function testGetMessageReturnsCustomMessage(): void - { - $messages = ['trim' => 'Trim applied']; - $sanitize = new Sanitize(['trim'], $messages); - - $this->assertSame('Trim applied', $sanitize->getMessage('trim')); - } - - public function testGetMessageReturnsNullForUnknownProcessor(): void - { - $messages = ['trim' => 'Trim applied']; - $sanitize = new Sanitize(['trim'], $messages); - - $this->assertNull($sanitize->getMessage('htmlspecialchars')); - } -} diff --git a/tests/Conformance/ArchitecturalContractTest.php b/tests/Conformance/ArchitecturalContractTest.php new file mode 100644 index 0000000..8343745 --- /dev/null +++ b/tests/Conformance/ArchitecturalContractTest.php @@ -0,0 +1,87 @@ +assertTrue($ref->isFinal(), "{$class} must be final"); + $this->assertTrue($ref->isReadOnly(), "{$class} must be readonly"); + } + } + + #[Test] + public function testAllRulesImplementContract(): void + { + foreach (self::RULE_CLASSES as $class) { + $this->assertTrue( + is_subclass_of($class, \KaririCode\Sanitizer\Contract\SanitizationRule::class), + "{$class} must implement SanitizationRule", + ); + } + } + + #[Test] + public function testRuleCount(): void + { + $this->assertCount(33, self::RULE_CLASSES); + } +} diff --git a/tests/Conformance/ImmutableStateTest.php b/tests/Conformance/ImmutableStateTest.php new file mode 100644 index 0000000..ec02043 --- /dev/null +++ b/tests/Conformance/ImmutableStateTest.php @@ -0,0 +1,39 @@ + 1]); + $ctx2 = $ctx->withField('email'); + + $this->assertNotSame($ctx, $ctx2); + $this->assertSame('', $ctx->getFieldName()); + $this->assertSame('email', $ctx2->getFieldName()); + } + + #[Test] + public function testContextWithParametersReturnsNewInstance(): void + { + $ctx = SanitizationContextImpl::create([]); + $ctx2 = $ctx->withParameters(['max' => 10]); + + $this->assertNotSame($ctx, $ctx2); + $this->assertSame([], $ctx->getParameters()); + $this->assertSame(['max' => 10], $ctx2->getParameters()); + } +} diff --git a/tests/Integration/FullRuleRegistrationTest.php b/tests/Integration/FullRuleRegistrationTest.php new file mode 100644 index 0000000..f6ad364 --- /dev/null +++ b/tests/Integration/FullRuleRegistrationTest.php @@ -0,0 +1,66 @@ +createRegistry(); + + foreach ($registry->aliases() as $alias) { + $rule = $registry->resolve($alias); + $this->assertNotEmpty($rule->getName(), "Rule '{$alias}' has empty name."); + } + } + + #[Test] + public function testFullPipelineIntegration(): void + { + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + [ + 'name' => ' walmir SILVA ', + 'email' => ' Admin@Kariricode.ORG ', + 'cpf' => '52998224725', + 'bio' => 'A very long biography that should be truncated at some point for display', + 'score' => '42.678', + 'active' => 'yes', + 'date' => '28/02/2025', + 'html' => 'Bold', + 'tags' => 'hello world test', + ], + [ + 'name' => ['trim', 'normalize_whitespace', 'capitalize'], + 'email' => ['trim', 'lower_case', 'email_filter'], + 'cpf' => ['format_cpf'], + 'bio' => [['truncate', ['max' => 30]]], + 'score' => ['to_float', ['round', ['precision' => 1]]], + 'active' => ['to_bool'], + 'date' => [['normalize_date', ['from' => 'd/m/Y', 'to' => 'Y-m-d']]], + 'html' => ['strip_tags'], + 'tags' => ['trim', 'normalize_whitespace', 'slug'], + ], + ); + + $this->assertSame('Walmir Silva', $result->get('name')); + $this->assertSame('admin@kariricode.org', $result->get('email')); + $this->assertSame('529.982.247-25', $result->get('cpf')); + $this->assertSame('A very long biography that ...', $result->get('bio')); + $this->assertSame(42.7, $result->get('score')); + $this->assertTrue($result->get('active')); + $this->assertSame('2025-02-28', $result->get('date')); + $this->assertSame('alert("xss")Bold', $result->get('html')); + $this->assertSame('hello-world-test', $result->get('tags')); + } +} diff --git a/tests/Integration/ProcessorBridgeTest.php b/tests/Integration/ProcessorBridgeTest.php new file mode 100644 index 0000000..da2b604 --- /dev/null +++ b/tests/Integration/ProcessorBridgeTest.php @@ -0,0 +1,44 @@ +createEngine(); + $bridge = new ProcessorBridge($engine, [ + 'name' => ['trim', 'capitalize'], + ]); + + $output = $bridge->process(['name' => ' walmir silva ']); + + $this->assertArrayHasKey('data', $output); + $this->assertArrayHasKey('result', $output); + $this->assertSame('Walmir Silva', $output['data']['name']); + $this->assertInstanceOf(SanitizationResult::class, $output['result']); + } + + #[Test] + public function testProcessWithEmptyDataReturnsEmptyArrays(): void + { + $engine = new SanitizerServiceProvider()->createEngine(); + $bridge = new ProcessorBridge($engine, []); + + $output = $bridge->process([]); + + $this->assertSame([], $output['data']); + $this->assertInstanceOf(SanitizationResult::class, $output['result']); + } +} diff --git a/tests/Processor/Domain/HtmlPurifierSanitizerTest.php b/tests/Processor/Domain/HtmlPurifierSanitizerTest.php deleted file mode 100644 index b5bd194..0000000 --- a/tests/Processor/Domain/HtmlPurifierSanitizerTest.php +++ /dev/null @@ -1,149 +0,0 @@ -sanitizer = new HtmlPurifierSanitizer(); - } - - public function testProcessRemovesDisallowedTagsPreservingContent(): void - { - $input = '

This is a test.

'; - - $this->sanitizer->configure([ - 'allowedTags' => ['p'], - ]); - - // Nota: Agora esperamos um único espaço após a remoção do script - $expected = '

This is a test.

'; - $this->assertEquals($expected, $this->sanitizer->process($input)); - } - - public function testProcessPreservesContentOfRemovedTags(): void - { - $input = '
This is a nested text
'; - - $this->sanitizer->configure([ - 'allowedTags' => [], - ]); - - $expected = 'This is a nested text'; - $this->assertEquals($expected, $this->sanitizer->process($input)); - } - - public function testProcessRemovesDisallowedAttributes(): void - { - $input = '
Link'; - - $this->sanitizer->configure([ - 'allowedTags' => ['a'], - 'allowedAttributes' => ['href' => ['a']], - ]); - - $expected = 'Link'; - $this->assertEquals($expected, $this->sanitizer->process($input)); - } - - public function testProcessRemovesHtmlComments(): void - { - $input = '

This is a test.

'; - - $this->sanitizer->configure([ - 'allowedTags' => ['p'], - ]); - - // Nota: Agora esperamos um único espaço após a remoção do comentário - $expected = '

This is a test.

'; - $this->assertEquals($expected, $this->sanitizer->process($input)); - } - - public function testConfigureChangesAllowedTags(): void - { - $this->sanitizer->configure([ - 'allowedTags' => ['p', 'strong'], - ]); - - $input = '

This is bold and italic.

'; - $expected = '

This is bold and italic.

'; - - $this->assertEquals($expected, $this->sanitizer->process($input)); - } - - // TODO: resolve fix - // public function testConfigureChangesAllowedAttributes(): void - // { - // $this->sanitizer->configure([ - // 'allowedTags' => ['p'], - // 'allowedAttributes' => ['class' => ['p']], - // ]); - - // $input = '

This is a test.

'; - - // $expected = '

This is a test.

'; - - // $this->assertEquals($expected, $this->sanitizer->process($input)); - // } - - public function testRemovesTagButPreservesAttributeContent(): void - { - $input = '

Title

Text with link

'; - - $this->sanitizer->configure([ - 'allowedTags' => ['p'], - ]); - - $expected = 'Title

Text with link

'; - $this->assertEquals($expected, $this->sanitizer->process($input)); - } - - public function testProcessHandlesNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - /** - * @doesNotPerformAssertions - */ - public function testProcessHandlesInvalidHtml(): void - { - // Removendo este teste por enquanto, pois o comportamento pode variar - // dependendo da versão do libxml e da configuração do sistema - } - - public function testProcessPreservesNestedStructure(): void - { - $input = '

First

  • Item 1
  • Item 2
'; - - $this->sanitizer->configure([ - 'allowedTags' => ['ul', 'li'], - ]); - - $expected = 'First
  • Item 1
  • Item 2
'; - $this->assertEquals($expected, $this->sanitizer->process($input)); - } - - public function testProcessIgnoresAttributesOfNonAllowedTags(): void - { - $input = '

Content

'; - - $this->sanitizer->configure([ - 'allowedTags' => [], - 'allowedAttributes' => ['class' => ['div', 'p']], - ]); - - $expected = 'Content'; - $this->assertEquals($expected, $this->sanitizer->process($input)); - } -} diff --git a/tests/Processor/Domain/JsonSanitizerTest.php b/tests/Processor/Domain/JsonSanitizerTest.php deleted file mode 100644 index 10a178a..0000000 --- a/tests/Processor/Domain/JsonSanitizerTest.php +++ /dev/null @@ -1,26 +0,0 @@ -assertEquals($expected, $sanitizer->process($input)); - } - - public function testJsonSanitizerWithInvalidJson(): void - { - $this->expectException(\InvalidArgumentException::class); - $sanitizer = new JsonSanitizer(); - $sanitizer->process('{invalid json}'); - } -} diff --git a/tests/Processor/Domain/MarkdownSanitizerTest.php b/tests/Processor/Domain/MarkdownSanitizerTest.php deleted file mode 100644 index 98d3fd9..0000000 --- a/tests/Processor/Domain/MarkdownSanitizerTest.php +++ /dev/null @@ -1,21 +0,0 @@ -assertEquals( - 'This is \*emphasized\* and this is \*\*bold\*\*', - $sanitizer->process('This is *emphasized* and this is **bold**') - ); - $this->assertEquals('\\# Heading', $sanitizer->process('# Heading')); - } -} diff --git a/tests/Processor/Input/AlphanumericSanitizerTest.php b/tests/Processor/Input/AlphanumericSanitizerTest.php deleted file mode 100644 index d0e81a2..0000000 --- a/tests/Processor/Input/AlphanumericSanitizerTest.php +++ /dev/null @@ -1,106 +0,0 @@ -sanitizer = new AlphanumericSanitizer(); - } - - /** - * @dataProvider alphanumericDataProvider - */ - public static function testAlphanumericSanitization(string $input, array $config, string $expected): void - { - $sanitizer = new AlphanumericSanitizer(); - $sanitizer->configure($config); - self::assertSame($expected, $sanitizer->process($input)); - } - - public function testHandleNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - public static function alphanumericDataProvider(): array - { - return [ - 'basic alphanumeric' => [ - 'Test123', - [], - 'Test123', - ], - 'with special characters' => [ - 'Test@123!#$', - [], - 'Test123', - ], - 'allow spaces' => [ - 'Test 123 Text', - ['allowSpace' => true], - 'Test 123 Text', - ], - 'allow underscore' => [ - 'test_123_text', - ['allowUnderscore' => true], - 'test_123_text', - ], - 'allow dash' => [ - 'test-123-text', - ['allowDash' => true], - 'test-123-text', - ], - 'allow dot' => [ - 'test.123.text', - ['allowDot' => true], - 'test.123.text', - ], - 'multiple allowed chars' => [ - 'test.123-text_example 456', - [ - 'allowSpace' => true, - 'allowUnderscore' => true, - 'allowDash' => true, - 'allowDot' => true, - ], - 'test.123-text_example 456', - ], - 'lowercase conversion' => [ - 'TEST123', - ['preserveCase' => false], - 'test123', - ], - 'empty string' => [ - '', - [], - '', - ], - 'only special chars' => [ - '@#$%^&', - [], - '', - ], - 'mixed case with spaces' => [ - 'Test USER 123', - ['allowSpace' => true], - 'Test USER 123', - ], - 'mixed special chars' => [ - 'test@user.name-123_example', - ['allowDot' => true, 'allowDash' => true], - 'testuser.name-123example', - ], - ]; - } -} diff --git a/tests/Processor/Input/EmailSanitizerTest.php b/tests/Processor/Input/EmailSanitizerTest.php deleted file mode 100644 index e6f2153..0000000 --- a/tests/Processor/Input/EmailSanitizerTest.php +++ /dev/null @@ -1,97 +0,0 @@ -sanitizer = new EmailSanitizer(); - } - - /** - * @dataProvider emailDataProvider - */ - public static function testEmailSanitization(string $input, string $expected): void - { - $sanitizer = new EmailSanitizer(); - self::assertSame($expected, $sanitizer->process($input)); - } - - public function testCustomTypoReplacements(): void - { - $this->sanitizer->configure([ - 'typoReplacements' => [ - 'at' => '@', - '[dot]' => '.', - ], - ]); - - $input = 'walmir[dot]silvaatexample[dot]com'; - $expected = 'walmir.silva@example.com'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testCustomDomainReplacements(): void - { - $this->sanitizer->configure([ - 'domainReplacements' => [ - 'kariricode.com' => ['kariri-code.com', 'kariricode.com.br'], - ], - ]); - - $input = 'walmir@kariri-code.com'; - $expected = 'walmir@kariricode.com'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testHandleNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - public static function emailDataProvider(): array - { - return [ - 'basic email' => [ - 'Walmir.Silva@Example.com', - 'walmir.silva@example.com', - ], - 'with spaces' => [ - ' walmir.silva@example.com ', - 'walmir.silva@example.com', - ], - 'common typos' => [ - 'walmir,,silva@gmial.com', - 'walmir.silva@gmail.com', - ], - 'multiple dots' => [ - 'walmir...silva@example.com', - 'walmir.silva@example.com', - ], - 'mailto prefix' => [ - 'mailto:walmir.silva@example.com', - 'walmir.silva@example.com', - ], - 'domain typos' => [ - 'walmir@gmaill.com', - 'walmir@gmail.com', - ], - 'mixed case' => [ - 'WALMIR.SILVA@EXAMPLE.COM', - 'walmir.silva@example.com', - ], - ]; - } -} diff --git a/tests/Processor/Input/HtmlSpecialCharsSanitizerTest.php b/tests/Processor/Input/HtmlSpecialCharsSanitizerTest.php deleted file mode 100644 index 63edab8..0000000 --- a/tests/Processor/Input/HtmlSpecialCharsSanitizerTest.php +++ /dev/null @@ -1,74 +0,0 @@ -sanitizer = new HtmlSpecialCharsSanitizer(); - } - - /** - * @dataProvider htmlSpecialCharsProvider - */ - public static function testBasicSanitization(string $input, string $expected): void - { - $sanitizer = new HtmlSpecialCharsSanitizer(); - self::assertSame($expected, $sanitizer->process($input)); - } - - public function testCustomConfiguration(): void - { - $this->sanitizer->configure([ - 'flags' => ENT_NOQUOTES, - 'encoding' => 'ISO-8859-1', - 'doubleEncode' => false, - ]); - - $input = '
&test
'; - $expected = '<div class="test">&test</div>'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testHandleNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - public static function htmlSpecialCharsProvider(): array - { - return [ - 'basic html' => [ - '

Test

', - '<p>Test</p>', - ], - 'attributes' => [ - '
Content
', - '<div class="test">Content</div>', - ], - 'special chars' => [ - '& < > " \'', - '& < > " '', - ], - 'already encoded' => [ - '& < >', - '&amp; &lt; &gt;', - ], - 'mixed content' => [ - '

Test & Demo

', - '<p class="test">Test & Demo</p>', - ], - ]; - } -} diff --git a/tests/Processor/Input/NormalizeLineBreaksSanitizerTest.php b/tests/Processor/Input/NormalizeLineBreaksSanitizerTest.php deleted file mode 100644 index bf22613..0000000 --- a/tests/Processor/Input/NormalizeLineBreaksSanitizerTest.php +++ /dev/null @@ -1,81 +0,0 @@ -sanitizer = new NormalizeLineBreaksSanitizer(); - } - - /** - * @dataProvider lineBreaksProvider - */ - public static function testBasicNormalization(string $input, string $expected): void - { - $sanitizer = new NormalizeLineBreaksSanitizer(); - self::assertSame($expected, $sanitizer->process($input)); - } - - public function testCustomLineEnding(): void - { - $this->sanitizer->configure(['lineEnding' => 'windows']); - $input = "Line1\nLine2\rLine3\r\nLine4"; - $expected = "Line1\r\nLine2\r\nLine3\r\nLine4"; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testInvalidLineEndingConfiguration(): void - { - $this->sanitizer->configure(['lineEnding' => 'invalid']); - $input = "Line1\nLine2"; - - $this->assertSame("Line1\nLine2", $this->sanitizer->process($input)); - } - - public function testHandleNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - public static function lineBreaksProvider(): array - { - return [ - 'unix line endings' => [ - "Line1\nLine2\nLine3", - "Line1\nLine2\nLine3", - ], - 'windows line endings' => [ - "Line1\r\nLine2\r\nLine3", - "Line1\nLine2\nLine3", - ], - 'mac line endings' => [ - "Line1\rLine2\rLine3", - "Line1\nLine2\nLine3", - ], - 'mixed line endings' => [ - "Line1\rLine2\r\nLine3\nLine4", - "Line1\nLine2\nLine3\nLine4", - ], - 'no line endings' => [ - 'SingleLine', - 'SingleLine', - ], - 'multiple consecutive line endings' => [ - "Line1\n\n\nLine2", - "Line1\n\n\nLine2", - ], - ]; - } -} diff --git a/tests/Processor/Input/NumericSanitizerTest.php b/tests/Processor/Input/NumericSanitizerTest.php deleted file mode 100644 index be19208..0000000 --- a/tests/Processor/Input/NumericSanitizerTest.php +++ /dev/null @@ -1,99 +0,0 @@ -sanitizer = new NumericSanitizer(); - } - - /** - * @dataProvider numericDataProvider - */ - public static function testNumericSanitization(string $input, array $config, string $expected): void - { - $sanitizer = new NumericSanitizer(); - $sanitizer->configure($config); - self::assertSame($expected, $sanitizer->process($input)); - } - - public function testCustomDecimalSeparator(): void - { - $this->sanitizer->configure([ - 'allowDecimal' => true, - 'decimalSeparator' => ',', - ]); - - $input = '123,45'; - $expected = '123,45'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testHandleNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - public static function numericDataProvider(): array - { - return [ - 'basic integer' => [ - '123', - [], - '123', - ], - 'with decimals allowed' => [ - '123.45', - ['allowDecimal' => true], - '123.45', - ], - 'with decimals disabled' => [ - '123.45', - ['allowDecimal' => false], - '12345', - ], - 'negative number allowed' => [ - '-123.45', - ['allowDecimal' => true, 'allowNegative' => true], - '-123.45', - ], - 'negative number disabled' => [ - '-123.45', - ['allowDecimal' => true, 'allowNegative' => false], - '123.45', - ], - 'mixed characters' => [ - 'abc123.45xyz', - ['allowDecimal' => true], - '123.45', - ], - 'multiple decimal points' => [ - '123.45.67', - ['allowDecimal' => true], - '123.4567', - ], - 'only non-numeric' => [ - 'abc', - [], - '', - ], - 'empty string' => [ - '', - [], - '', - ], - ]; - } -} diff --git a/tests/Processor/Input/PhoneSanitizerTest.php b/tests/Processor/Input/PhoneSanitizerTest.php deleted file mode 100644 index 216360a..0000000 --- a/tests/Processor/Input/PhoneSanitizerTest.php +++ /dev/null @@ -1,107 +0,0 @@ -sanitizer = new PhoneSanitizer(); - } - - /** - * @dataProvider phoneDataProvider - */ - public static function testPhoneSanitization(string $input, array $config, string $expected): void - { - $sanitizer = new PhoneSanitizer(); - $sanitizer->configure($config); - self::assertSame($expected, $sanitizer->process($input)); - } - - public function testHandleNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - public static function phoneDataProvider(): array - { - return [ - 'basic number without format' => [ - '(11) 99999-9999', - [], - '11999999999', - ], - 'mixed characters' => [ - 'Tel: +55 (11) 99999-9999', - [], - '5511999999999', - ], - 'with special characters' => [ - '11.99999-9999', - [], - '11999999999', - ], - 'with optional formatting' => [ - '11999999999', - [ - 'applyFormat' => true, - 'format' => '(##) #####-####', - ], - '(11) 99999-9999', - ], - 'international with format' => [ - '5511999999999', - [ - 'applyFormat' => true, - 'format' => '+## (##) #####-####', - ], - '+55 (11) 99999-9999', - ], - 'partial number' => [ - '11999', - [ - 'applyFormat' => true, - 'format' => '(##) #####-####', - ], - '11999', - ], - 'custom format' => [ - '11999999999', - [ - 'applyFormat' => true, - 'format' => '## # ####-####', - ], - '11 9 9999-9999', - ], - 'empty string' => [ - '', - [], - '', - ], - 'only non-numeric' => [ - '(abc) def-ghij', - [], - '', - ], - 'with custom placeholder' => [ - '11999999999', - [ - 'applyFormat' => true, - 'format' => '(**)_*****-****', - 'placeholder' => '*', - ], - '(11)_99999-9999', - ], - ]; - } -} diff --git a/tests/Processor/Input/StripTagsSanitizerTest.php b/tests/Processor/Input/StripTagsSanitizerTest.php deleted file mode 100644 index aa2935d..0000000 --- a/tests/Processor/Input/StripTagsSanitizerTest.php +++ /dev/null @@ -1,96 +0,0 @@ -sanitizer = new StripTagsSanitizer(); - } - - /** - * @dataProvider stripTagsProvider - */ - public function testBasicStripping(string $input, array $config, string $expected): void - { - $this->sanitizer->configure($config); - self::assertSame($expected, $this->sanitizer->process($input)); - } - - public function testWithAllowedTagsAndAttributes(): void - { - $this->sanitizer->configure([ - 'allowedTags' => ['p', 'div'], - 'keepSafeAttributes' => true, - 'safeAttributes' => ['class', 'id'], - ]); - - $input = '

Text

'; - $expected = '

Text

'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testWithoutSafeAttributes(): void - { - $this->sanitizer->configure([ - 'allowedTags' => ['p'], - 'keepSafeAttributes' => false, - ]); - - $input = '

Text

'; - $expected = '

Text

'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testHandleNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - public static function stripTagsProvider(): array - { - return [ - 'no tags' => [ - 'Plain text content', - [], - 'Plain text content', - ], - 'simple tags' => [ - '

Paragraph

Division
', - [], - 'ParagraphDivision', - ], - 'nested tags' => [ - '

Nested content

', - ['allowedTags' => ['div']], - '
Nested content
', - ], - 'mixed content' => [ - '

Text

', - ['allowedTags' => ['p']], - '

Text

', - ], - 'with attributes' => [ - '

Text

', - [ - 'allowedTags' => ['p'], - 'keepSafeAttributes' => true, - 'safeAttributes' => ['class'], - ], - '

Text

', - ], - ]; - } -} diff --git a/tests/Processor/Input/TrimSanitizerTest.php b/tests/Processor/Input/TrimSanitizerTest.php deleted file mode 100644 index f50545d..0000000 --- a/tests/Processor/Input/TrimSanitizerTest.php +++ /dev/null @@ -1,111 +0,0 @@ -sanitizer = new TrimSanitizer(); - } - - /** - * @dataProvider trimDataProvider - */ - public static function testBasicTrim(string $input, array $config, string $expected): void - { - $sanitizer = new TrimSanitizer(); - $sanitizer->configure($config); - self::assertSame($expected, $sanitizer->process($input)); - } - - public function testCustomCharacterMask(): void - { - $this->sanitizer->configure(['characterMask' => 'xyz']); - $this->assertSame('123', $this->sanitizer->process('xyz123xxxyz')); - - $this->sanitizer->configure(['characterMask' => 'ab']); - $this->assertSame('123', $this->sanitizer->process('aabb123bbaa')); - - $this->sanitizer->configure(['characterMask' => '_']); - $this->assertSame('abc-123', $this->sanitizer->process('__abc-123__')); - } - - public function testSelectiveTrimming(): void - { - // Test left trim only - $sanitizer = new TrimSanitizer(); - $sanitizer->configure(['trimRight' => false]); - $this->assertSame('test ', $sanitizer->process(' test ')); - - // Test right trim only - $sanitizer = new TrimSanitizer(); - $sanitizer->configure(['trimLeft' => false]); - $this->assertSame(' test', $sanitizer->process(' test ')); - - // Test no trimming - $sanitizer = new TrimSanitizer(); - $sanitizer->configure(['trimLeft' => false, 'trimRight' => false]); - $this->assertSame(' test ', $sanitizer->process(' test ')); - } - - public function testHandleNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - public static function trimDataProvider(): array - { - return [ - 'basic spaces' => [ - ' test ', - [], - 'test', - ], - 'tabs and newlines' => [ - "\t\ttest\n\n", - [], - 'test', - ], - 'mixed whitespace' => [ - " \t\n\r\0\x0Btest \t\n\r\0\x0B", - [], - 'test', - ], - 'custom mask' => [ - '...test...', - ['characterMask' => '.'], - 'test', - ], - 'left trim only' => [ - ' test ', - ['trimRight' => false], - 'test ', - ], - 'right trim only' => [ - ' test ', - ['trimLeft' => false], - ' test', - ], - 'no trim' => [ - ' test ', - ['trimLeft' => false, 'trimRight' => false], - ' test ', - ], - 'preserve characters in middle' => [ - 'xyz123xxxyz', - ['characterMask' => 'xyz'], - '123', - ], - ]; - } -} diff --git a/tests/Processor/Input/UrlSanitizerTest.php b/tests/Processor/Input/UrlSanitizerTest.php deleted file mode 100644 index 5ef32ab..0000000 --- a/tests/Processor/Input/UrlSanitizerTest.php +++ /dev/null @@ -1,89 +0,0 @@ -sanitizer = new UrlSanitizer(); - } - - /** - * @dataProvider urlDataProvider - */ - public static function testUrlSanitization(string $input, array $config, string $expected): void - { - $sanitizer = new UrlSanitizer(); - $sanitizer->configure($config); - self::assertSame($expected, $sanitizer->process($input)); - } - - public function testCustomProtocol(): void - { - $this->sanitizer->configure([ - 'enforceProtocol' => true, - 'defaultProtocol' => 'http://', - ]); - - $input = 'example.com'; - $expected = 'http://example.com'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testHandleNonStringInput(): void - { - $this->expectException(SanitizationException::class); - $this->sanitizer->process(123); - } - - public static function urlDataProvider(): array - { - return [ - 'basic url' => [ - 'example.com', - ['enforceProtocol' => true], - 'https://example.com', - ], - 'with protocol' => [ - 'https://example.com', - ['enforceProtocol' => true], - 'https://example.com', - ], - 'multiple slashes' => [ - 'https://example.com//path///to//resource', - [], - 'https://example.com/path/to/resource', - ], - 'with trailing slash' => [ - 'https://example.com/', - ['removeTrailingSlash' => true], - 'https://example.com', - ], - 'keep trailing slash' => [ - 'https://example.com/', - ['removeTrailingSlash' => false], - 'https://example.com/', - ], - 'no protocol enforcement' => [ - 'example.com', - ['enforceProtocol' => false], - 'example.com', - ], - 'with spaces' => [ - ' https://example.com ', - [], - 'https://example.com', - ], - ]; - } -} diff --git a/tests/Processor/Security/FilenameSanitizerTest.php b/tests/Processor/Security/FilenameSanitizerTest.php deleted file mode 100644 index 4b94183..0000000 --- a/tests/Processor/Security/FilenameSanitizerTest.php +++ /dev/null @@ -1,108 +0,0 @@ -sanitizer = new FilenameSanitizer(); - } - - public function testBasicFilenameSanitization(): void - { - $input = 'file@name!.txt'; - $expected = 'file_name.txt'; - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testSanitizationWithoutExtension(): void - { - $this->sanitizer->configure(['preserveExtension' => false]); - $input = 'file@name!.txt'; - $expected = 'file_name_txt'; - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testCustomReplacementCharacter(): void - { - $this->sanitizer->configure(['replacement' => '-']); - $input = 'file@name!.txt'; - $expected = 'file-name.txt'; - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testInvalidReplacementCharacter(): void - { - $this->sanitizer->configure(['replacement' => '*']); - $input = 'file@name!.txt'; - $expected = 'file_name.txt'; - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testAllowedCharacters(): void - { - $this->sanitizer->configure(['allowedChars' => ['a-z', 'A-Z', '0-9']]); - $input = 'file_name-123.txt'; - $expected = 'file_name_123.txt'; - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testCustomAllowedCharacters(): void - { - $this->sanitizer->configure(['allowedChars' => ['a-z', 'A-Z', '0-9', '_']]); - $input = 'file@name!.txt'; - $expected = 'file_name.txt'; - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testEmptyFilenameReturnsEmptyString(): void - { - $input = ''; - $expected = ''; - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testFilenameWithoutExtension(): void - { - $input = 'filename@!with_no_extension'; - $expected = 'filename_with_no_extension'; - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testFilenameWithMultipleExtensions(): void - { - $input = 'file.name@!.tar.gz'; - $expected = 'file.name.tar.gz'; - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testNonStringInputThrowsException(): void - { - $this->expectException(SanitizationException::class); - $this->expectExceptionMessage('Input must be a string'); - $this->sanitizer->process(12345); - } - - public function testObjectInputThrowsException(): void - { - $this->expectException(SanitizationException::class); - $this->expectExceptionMessage('Input must be a string'); - $this->sanitizer->process(new \stdClass()); - } - - public function testArrayInputThrowsException(): void - { - $this->expectException(SanitizationException::class); - $this->expectExceptionMessage('Input must be a string'); - $this->sanitizer->process(['invalid', 'input']); - } -} diff --git a/tests/Processor/Security/SqlInjectionSanitizerTest.php b/tests/Processor/Security/SqlInjectionSanitizerTest.php deleted file mode 100644 index 597523a..0000000 --- a/tests/Processor/Security/SqlInjectionSanitizerTest.php +++ /dev/null @@ -1,103 +0,0 @@ -sanitizer = new SqlInjectionSanitizer(); - } - - public function testProcessWithSafeInput(): void - { - $input = 'Safe input without SQL keywords'; - $expected = 'Safe input without SQL keywords'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testProcessWithSqlInjectionAttempt(): void - { - $input = "SELECT * FROM users WHERE name='admin'--"; - $expected = "SELECT * FROM users WHERE name=\\'admin\\'"; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testProcessWithCustomEscapeMap(): void - { - $customSanitizer = new SqlInjectionSanitizer(); - $customSanitizer->configure([ - 'escapeMap' => ["'" => "''", '\\' => '\\\\'], - ]); - - $input = "O'Reilly"; - $expected = "O''Reilly"; - - $this->assertSame($expected, $customSanitizer->process($input)); - } - - public function testProcessWithEscapedCharacters(): void - { - $input = "This contains a null byte: \x00 and a quote: '"; - $expected = "This contains a null byte: \\0 and a quote: \\'"; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testProcessWithMultiLineComment(): void - { - $input = 'SELECT * FROM users /* hidden comment */ WHERE id = 1;'; - $expected = 'SELECT * FROM users WHERE id = 1'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testProcessWithSemicolons(): void - { - $input = 'DROP TABLE users;'; - $expected = 'DROP TABLE users'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testProcessWithNormalizedWhitespace(): void - { - $input = 'SELECT * FROM users'; - $expected = 'SELECT * FROM users'; - - $this->assertSame($expected, $this->sanitizer->process($input)); - } - - public function testRemoveSuspiciousPatterns(): void - { - $reflection = new \ReflectionClass(SqlInjectionSanitizer::class); - $method = $reflection->getMethod('removeSuspiciousPatterns'); - $method->setAccessible(true); - - $input = 'SELECT * FROM users WHERE id = 1; -- malicious comment'; - $expected = 'SELECT * FROM users WHERE id = 1 '; - - $this->assertSame($expected, $method->invoke($this->sanitizer, $input)); - } - - public function testEscapeString(): void - { - $reflection = new \ReflectionClass(SqlInjectionSanitizer::class); - $method = $reflection->getMethod('escapeString'); - $method->setAccessible(true); - - $input = "This contains a null byte: \x00 and a quote: '"; - $expected = "This contains a null byte: \\0 and a quote: \\'"; - - $this->assertSame($expected, $method->invoke($this->sanitizer, $input)); - } -} diff --git a/tests/Processor/Security/XssSanitizerTest.php b/tests/Processor/Security/XssSanitizerTest.php deleted file mode 100644 index 13ef0e3..0000000 --- a/tests/Processor/Security/XssSanitizerTest.php +++ /dev/null @@ -1,20 +0,0 @@ -assertEquals( - '<script>alert("xss")</script>', - $sanitizer->process('') - ); - } -} diff --git a/tests/SanitizerTest.php b/tests/SanitizerTest.php deleted file mode 100644 index 3e8feb0..0000000 --- a/tests/SanitizerTest.php +++ /dev/null @@ -1,193 +0,0 @@ -registry = $this->createMock(ProcessorRegistry::class); - $this->sanitizer = new Sanitizer($this->registry); - } - - private function createProcessorMock(mixed $input, mixed $output): Processor|MockObject - { - $processor = $this->createMock(Processor::class); - $processor->method('process') - ->with($input) - ->willReturn($output); - - return $processor; - } - - public function testSanitizeProcessesObjectProperties(): void - { - $testObject = new class { - #[Sanitize(processors: ['trim'])] - public string $name = ' Walmir Silva '; - - #[Sanitize(processors: ['email'])] - public string $email = 'walmir.silva@example..com'; - }; - - // Configure TrimSanitizer mock to actually perform the trim operation - $trimProcessor = $this->getMockBuilder(Processor::class) - ->getMock(); - - $trimProcessor->method('process') - ->willReturnCallback(function ($input) { - return trim($input); // Execute actual trim - }); - - $emailProcessor = $this->createMock(Processor::class); - $emailProcessor->method('process') - ->with('walmir.silva@example..com') - ->willReturn('walmir.silva@example.com'); - - // Ensure registry returns processors correctly - $this->registry->method('get') - ->willReturnCallback(function ($type, $name) use ($trimProcessor, $emailProcessor) { - if ('trim' === $name) { - return $trimProcessor; - } - if ('email' === $name) { - return $emailProcessor; - } - - return null; - }); - - $result = $this->sanitizer->sanitize($testObject); - - $this->assertSame('Walmir Silva', $testObject->name); - $this->assertSame('walmir.silva@example.com', $testObject->email); - $this->assertInstanceOf(SanitizationResult::class, $result); - } - - public function testSanitizeHandlesNonProcessableAttributes(): void - { - $testObject = new class { - #[Sanitize(processors: ['trim'])] - public string $processable = ' trim me '; - - public string $nonProcessable = 'leave me alone'; - }; - - $trimProcessor = $this->createProcessorMock( - ' trim me ', - 'trim me' - ); - - $this->registry->method('get') - ->with('sanitizer', 'trim') - ->willReturn($trimProcessor); - - $result = $this->sanitizer->sanitize($testObject); - - $this->assertSame('trim me', $testObject->processable); - $this->assertSame('leave me alone', $testObject->nonProcessable); - $this->assertInstanceOf(SanitizationResult::class, $result); - } - - public function testSanitizeHandlesExceptionsAndUsesFallbackValue(): void - { - $testObject = new class { - #[Sanitize(processors: ['problematic'], messages: ['fallback' => 'Processing failed'])] - public string $problematic = 'cause problem'; - }; - - $problematicProcessor = $this->createMock(Processor::class); - $problematicProcessor->method('process') - ->willThrowException(new \Exception('Processing failed')); - - $this->registry->method('get') - ->with('sanitizer', 'problematic') - ->willReturn($problematicProcessor); - - $result = $this->sanitizer->sanitize($testObject); - - $this->assertSame('cause problem', $testObject->problematic); - $this->assertInstanceOf(SanitizationResult::class, $result); - } - - public function testSanitizeHandlesPrivateAndProtectedProperties(): void - { - $testObject = new class { - #[Sanitize(processors: ['trim'])] - private string $privateProp = ' private '; - - #[Sanitize(processors: ['trim'])] - protected string $protectedProp = ' protected '; - - public function getPrivateProp(): string - { - return $this->privateProp; - } - - public function getProtectedProp(): string - { - return $this->protectedProp; - } - }; - - // Create a single trim processor for both properties - $trimProcessor = $this->createMock(Processor::class); - $trimProcessor->method('process') - ->willReturnMap([ - [' private ', 'private'], - [' protected ', 'protected'], - ]); - - $this->registry->method('get') - ->with('sanitizer', 'trim') - ->willReturn($trimProcessor); - - $result = $this->sanitizer->sanitize($testObject); - - $this->assertSame('private', $testObject->getPrivateProp()); - $this->assertSame('protected', $testObject->getProtectedProp()); - $this->assertInstanceOf(SanitizationResult::class, $result); - } - - public function testSanitizeHandlesMultipleProcessorsForSingleProperty(): void - { - $testObject = new class { - #[Sanitize(processors: ['trim', 'uppercase'])] - public string $multiProcessed = ' hello world '; - }; - - $trimProcessor = $this->createProcessorMock( - ' hello world ', - 'hello world' - ); - - $uppercaseProcessor = $this->createProcessorMock( - 'hello world', - 'HELLO WORLD' - ); - - $this->registry->method('get') - ->willReturnMap([ - ['sanitizer', 'trim', $trimProcessor], - ['sanitizer', 'uppercase', $uppercaseProcessor], - ]); - - $result = $this->sanitizer->sanitize($testObject); - - $this->assertSame('HELLO WORLD', $testObject->multiProcessed); - $this->assertInstanceOf(SanitizationResult::class, $result); - } -} diff --git a/tests/Trait/CaseTransformerTraitTest.php b/tests/Trait/CaseTransformerTraitTest.php deleted file mode 100644 index d44fa92..0000000 --- a/tests/Trait/CaseTransformerTraitTest.php +++ /dev/null @@ -1,96 +0,0 @@ -traitObject = new class { - use CaseTransformerTrait; - - public function callToLowerCase(string $input): string - { - return $this->toLowerCase($input); - } - - public function callToUpperCase(string $input): string - { - return $this->toUpperCase($input); - } - - public function callToCamelCase(string $input): string - { - return $this->toCamelCase($input); - } - }; - } - - /** - * @dataProvider toLowerCaseProvider - */ - public function testToLowerCase(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callToLowerCase($input)); - } - - /** - * @dataProvider toUpperCaseProvider - */ - public function testToUpperCase(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callToUpperCase($input)); - } - - /** - * @dataProvider toCamelCaseProvider - */ - public function testToCamelCase(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callToCamelCase($input)); - } - - public static function toLowerCaseProvider(): array - { - return [ - 'mixed case' => ['HelloWorld', 'helloworld'], - 'already lowercase' => ['hello', 'hello'], - 'all uppercase' => ['HELLO', 'hello'], - 'with numbers' => ['Hello123World', 'hello123world'], - 'with special chars' => ['Hello@World', 'hello@world'], - 'empty string' => ['', ''], - ]; - } - - public static function toUpperCaseProvider(): array - { - return [ - 'mixed case' => ['HelloWorld', 'HELLOWORLD'], - 'already uppercase' => ['HELLO', 'HELLO'], - 'all lowercase' => ['hello', 'HELLO'], - 'with numbers' => ['Hello123World', 'HELLO123WORLD'], - 'with special chars' => ['Hello@World', 'HELLO@WORLD'], - 'empty string' => ['', ''], - ]; - } - - public static function toCamelCaseProvider(): array - { - return [ - 'simple underscored' => ['hello_world', 'helloWorld'], - 'multiple underscores' => ['hello_beautiful_world', 'helloBeautifulWorld'], - 'already camel case' => ['helloWorld', 'helloWorld'], - 'uppercase' => ['HELLO_WORLD', 'helloWorld'], - 'with numbers' => ['hello_123_world', 'hello123World'], - 'empty string' => ['', ''], - 'single word' => ['hello', 'hello'], - ]; - } -} diff --git a/tests/Trait/CharacterFilterTraitTest.php b/tests/Trait/CharacterFilterTraitTest.php deleted file mode 100644 index 65bb5e3..0000000 --- a/tests/Trait/CharacterFilterTraitTest.php +++ /dev/null @@ -1,71 +0,0 @@ -traitObject = new class { - use CharacterFilterTrait; - - public function callFilterAllowedCharacters(string $input, string $allowed): string - { - return $this->filterAllowedCharacters($input, $allowed); - } - - public function callKeepOnlyAlphanumeric(string $input, array $additionalChars = []): string - { - return $this->keepOnlyAlphanumeric($input, $additionalChars); - } - }; - } - - /** - * @dataProvider filterAllowedCharactersProvider - */ - public function testFilterAllowedCharacters(string $input, string $allowed, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callFilterAllowedCharacters($input, $allowed)); - } - - /** - * @dataProvider keepOnlyAlphanumericProvider - */ - public function testKeepOnlyAlphanumeric(string $input, array $additionalChars, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callKeepOnlyAlphanumeric($input, $additionalChars)); - } - - public static function filterAllowedCharactersProvider(): array - { - return [ - 'basic filtering' => ['hello123!@#', 'a-z', 'hello'], - 'numbers only' => ['hello123!@#', '0-9', '123'], - 'mixed allowed chars' => ['hello123!@#', 'a-z0-9', 'hello123'], - 'special chars' => ['hello@world!', '@!', '@!'], - 'empty string' => ['', 'a-z', ''], - 'no allowed chars' => ['hello123', 'x-z', ''], - 'with spaces' => ['hello world', 'a-z ', 'hello world'], - ]; - } - - public static function keepOnlyAlphanumericProvider(): array - { - return [ - 'basic alphanumeric' => ['hello123!@#', [], 'hello123'], - 'with dash' => ['hello-123', ['-'], 'hello-123'], - 'with multiple chars' => ['hello@world!123', ['@', '!'], 'hello@world!123'], - 'empty string' => ['', [], ''], - 'only special chars' => ['!@#$%', [], ''], - 'with spaces' => ['hello world', [' '], 'hello world'], - ]; - } -} diff --git a/tests/Trait/CharacterReplacementTraitTest.php b/tests/Trait/CharacterReplacementTraitTest.php deleted file mode 100644 index d0937e3..0000000 --- a/tests/Trait/CharacterReplacementTraitTest.php +++ /dev/null @@ -1,95 +0,0 @@ -traitObject = new class { - use CharacterReplacementTrait; - - public function callReplaceConsecutiveCharacters(string $input, string $char, string $replacement): string - { - return $this->replaceConsecutiveCharacters($input, $char, $replacement); - } - - public function callReplaceMultipleCharacters(string $input, array $replacements): string - { - return $this->replaceMultipleCharacters($input, $replacements); - } - }; - } - - /** - * @dataProvider replaceConsecutiveCharactersProvider - */ - public function testReplaceConsecutiveCharacters(string $input, string $char, string $replacement, string $expected): void - { - $this->assertSame( - $expected, - $this->traitObject->callReplaceConsecutiveCharacters($input, $char, $replacement) - ); - } - - /** - * @dataProvider replaceMultipleCharactersProvider - */ - public function testReplaceMultipleCharacters(string $input, array $replacements, string $expected): void - { - $this->assertSame( - $expected, - $this->traitObject->callReplaceMultipleCharacters($input, $replacements) - ); - } - - public static function replaceConsecutiveCharactersProvider(): array - { - return [ - 'basic replacement' => ['hello....world', '.', '.', 'hello.world'], - 'multiple occurrences' => ['hi....there....now', '.', '.', 'hi.there.now'], - 'no consecutive chars' => ['hello.world', '.', '.', 'hello.world'], - 'empty string' => ['', '.', '.', ''], - 'special characters' => ['test***case', '*', '_', 'test_case'], - 'with spaces' => ['hello world', ' ', ' ', 'hello world'], - ]; - } - - public static function replaceMultipleCharactersProvider(): array - { - return [ - 'basic replacements' => [ - 'hello world', - ['hello' => 'hi', 'world' => 'earth'], - 'hi earth', - ], - 'no matches' => [ - 'test case', - ['foo' => 'bar'], - 'test case', - ], - 'empty string' => [ - '', - ['a' => 'b'], - '', - ], - 'special characters' => [ - 'test@case#example', - ['@' => 'at', '#' => 'hash'], - 'testatcasehashexample', - ], - 'overlapping replacements' => [ - 'hello', - ['hell' => 'heaven', 'llo' => 'goodbye'], - 'heaveno', - ], - ]; - } -} diff --git a/tests/Trait/DomSanitizerTraitTest.php b/tests/Trait/DomSanitizerTraitTest.php deleted file mode 100644 index db68b9c..0000000 --- a/tests/Trait/DomSanitizerTraitTest.php +++ /dev/null @@ -1,88 +0,0 @@ -traitObject = new class { - use DomSanitizerTrait; - - public function callCreateDom(string $input, bool $wrapInRoot = true): \DOMDocument - { - return $this->createDom($input, $wrapInRoot); - } - - public function callCleanDomOutput(\DOMDocument $dom): string - { - return $this->cleanDomOutput($dom); - } - }; - } - - public function testCreateDomWithWrapping(): void - { - $input = '

Test content

'; - $dom = $this->traitObject->callCreateDom($input); - - $this->assertInstanceOf(\DOMDocument::class, $dom); - $root = $dom->getElementById('temp-root'); - $this->assertNotNull($root); - $this->assertTrue($root->hasChildNodes()); - } - - public function testCreateDomWithoutWrapping(): void - { - $input = '

Test content

'; - $dom = $this->traitObject->callCreateDom($input, false); - - $this->assertInstanceOf(\DOMDocument::class, $dom); - $root = $dom->getElementById('temp-root'); - $this->assertNull($root); - } - - public function testCleanDomOutput(): void - { - $dom = new \DOMDocument(); - $dom->loadHTML('

Test

'); - - $output = $this->traitObject->callCleanDomOutput($dom); - $this->assertSame('

Test

', $output); - } - - public function testCreateDomWithSpecialCharacters(): void - { - $input = '

Test & content

'; - $dom = $this->traitObject->callCreateDom($input); - - $this->assertInstanceOf(\DOMDocument::class, $dom); - $html = $dom->saveHTML(); - $this->assertStringContainsString('Test & content', $html); - } - - public function testCreateDomWithNestedElements(): void - { - $input = '

Test content

'; - $dom = $this->traitObject->callCreateDom($input); - - $this->assertInstanceOf(\DOMDocument::class, $dom); - $root = $dom->getElementById('temp-root'); - $this->assertNotNull($root); - $this->assertTrue($root->hasChildNodes()); - } - - public function testCleanDomOutputWithEmptyDocument(): void - { - $dom = new \DOMDocument(); - $output = $this->traitObject->callCleanDomOutput($dom); - $this->assertSame('', $output); - } -} diff --git a/tests/Trait/HtmlCleanerTraitTest.php b/tests/Trait/HtmlCleanerTraitTest.php deleted file mode 100644 index 3f61352..0000000 --- a/tests/Trait/HtmlCleanerTraitTest.php +++ /dev/null @@ -1,133 +0,0 @@ -traitObject = new class { - use HtmlCleanerTrait; - - public function callRemoveScripts(string $input): string - { - return $this->removeScripts($input); - } - - public function callRemoveComments(string $input): string - { - return $this->removeComments($input); - } - - public function callRemoveStyle(string $input): string - { - return $this->removeStyle($input); - } - }; - } - - /** - * @dataProvider removeScriptsProvider - */ - public function testRemoveScripts(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callRemoveScripts($input)); - } - - /** - * @dataProvider removeCommentsProvider - */ - public function testRemoveComments(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callRemoveComments($input)); - } - - /** - * @dataProvider removeStyleProvider - */ - public function testRemoveStyle(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callRemoveStyle($input)); - } - - public static function removeScriptsProvider(): array - { - return [ - 'basic script' => [ - '

Text

', - '

Text

', - ], - 'script with attributes' => [ - '', - '', - ], - 'inline event handler' => [ - 'Click me', - 'Click me', - ], - 'multiple scripts' => [ - '

Text

', - '

Text

', - ], - 'no scripts' => [ - '

Clean text

', - '

Clean text

', - ], - ]; - } - - public static function removeCommentsProvider(): array - { - return [ - 'basic comment' => [ - '

Text

', - '

Text

', - ], - 'multiple comments' => [ - '

Text

', - '

Text

', - ], - 'multiline comment' => [ - "

Text

", - '

Text

', - ], - 'no comments' => [ - '

Clean text

', - '

Clean text

', - ], - 'nested comments' => [ - ' Comment -->', - '', - ], - ]; - } - - public static function removeStyleProvider(): array - { - return [ - 'basic style' => [ - '', - '', - ], - 'style with attributes' => [ - '', - '', - ], - 'multiple styles' => [ - '

Text

', - '

Text

', - ], - 'no styles' => [ - '

Clean text

', - '

Clean text

', - ], - ]; - } -} diff --git a/tests/Trait/NumericSanitizerTraitTest.php b/tests/Trait/NumericSanitizerTraitTest.php deleted file mode 100644 index 08d2997..0000000 --- a/tests/Trait/NumericSanitizerTraitTest.php +++ /dev/null @@ -1,71 +0,0 @@ -traitObject = new class { - use NumericSanitizerTrait; - - public function callExtractNumbers(string $input): string - { - return $this->extractNumbers($input); - } - - public function callPreserveDecimalPoint(string $input, string $decimalPoint = '.'): string - { - return $this->preserveDecimalPoint($input, $decimalPoint); - } - }; - } - - /** - * @dataProvider extractNumbersProvider - */ - public function testExtractNumbers(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callExtractNumbers($input)); - } - - /** - * @dataProvider preserveDecimalPointProvider - */ - public function testPreserveDecimalPoint(string $input, string $decimalPoint, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callPreserveDecimalPoint($input, $decimalPoint)); - } - - public static function extractNumbersProvider(): array - { - return [ - 'only numbers' => ['123456', '123456'], - 'mixed content' => ['abc123def456', '123456'], - 'with special chars' => ['!@#123$%^456', '123456'], - 'empty string' => ['', ''], - 'no numbers' => ['abcdef', ''], - 'with spaces' => ['123 456', '123456'], - ]; - } - - public static function preserveDecimalPointProvider(): array - { - return [ - 'simple decimal' => ['123.456', '.', '123.456'], - 'custom decimal point' => ['123,456', ',', '123,456'], - 'multiple decimal points' => ['123.456.789', '.', '123.456789'], - 'no decimal point' => ['123456', '.', '123456'], - 'only decimal point' => ['.', '.', '.'], - 'decimal at start' => ['.123', '.', '.123'], - 'decimal at end' => ['123.', '.', '123.'], - ]; - } -} diff --git a/tests/Trait/UrlSanitizerTraitTest.php b/tests/Trait/UrlSanitizerTraitTest.php deleted file mode 100644 index 012eb2f..0000000 --- a/tests/Trait/UrlSanitizerTraitTest.php +++ /dev/null @@ -1,73 +0,0 @@ -traitObject = new class { - use UrlSanitizerTrait; - - public function callNormalizeProtocol(string $url, string $defaultProtocol = 'https://'): string - { - return $this->normalizeProtocol($url, $defaultProtocol); - } - - public function callNormalizeSlashes(string $url): string - { - return $this->normalizeSlashes($url); - } - }; - } - - /** - * @dataProvider normalizeProtocolProvider - */ - public function testNormalizeProtocol(string $input, string $defaultProtocol, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callNormalizeProtocol($input, $defaultProtocol)); - } - - /** - * @dataProvider normalizeSlashesProvider - */ - public function testNormalizeSlashes(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callNormalizeSlashes($input)); - } - - public static function normalizeProtocolProvider(): array - { - return [ - 'no protocol' => ['example.com', 'https://', 'https://example.com'], - 'with http' => ['http://example.com', 'https://', 'http://example.com'], - 'with https' => ['https://example.com', 'http://', 'https://example.com'], - 'with ftp' => ['ftp://example.com', 'https://', 'ftp://example.com'], - 'with sftp' => ['sftp://example.com', 'https://', 'sftp://example.com'], - 'custom protocol' => ['example.com', 'http://', 'http://example.com'], - 'empty string' => ['', 'https://', 'https://'], - 'with extra slashes' => ['/example.com', 'https://', 'https://example.com'], - ]; - } - - public static function normalizeSlashesProvider(): array - { - return [ - 'normal url' => ['https://example.com/path', 'https://example.com/path'], - 'multiple slashes' => ['https://example.com//path', 'https://example.com/path'], - 'preserve protocol slashes' => ['https://example.com', 'https://example.com'], - 'complex path' => ['https://example.com/path//to///resource', 'https://example.com/path/to/resource'], - 'empty string' => ['', ''], - 'only slashes' => ['////', '/'], - 'mixed slashes' => ['http:///example.com//path', 'http://example.com/path'], - ]; - } -} diff --git a/tests/Trait/ValidationTraitTest.php b/tests/Trait/ValidationTraitTest.php deleted file mode 100644 index 0c668fe..0000000 --- a/tests/Trait/ValidationTraitTest.php +++ /dev/null @@ -1,93 +0,0 @@ -traitObject = new class { - use ValidationTrait; - - public function callIsNotEmpty(string $input): bool - { - return $this->isNotEmpty($input); - } - - public function callIsValidUtf8(string $input): bool - { - return $this->isValidUtf8($input); - } - - public function callContainsPattern(string $input, string $pattern): bool - { - return $this->containsPattern($input, $pattern); - } - }; - } - - /** - * @dataProvider isNotEmptyProvider - */ - public function testIsNotEmpty(string $input, bool $expected): void - { - $this->assertSame($expected, $this->traitObject->callIsNotEmpty($input)); - } - - /** - * @dataProvider isValidUtf8Provider - */ - public function testIsValidUtf8(string $input, bool $expected): void - { - $this->assertSame($expected, $this->traitObject->callIsValidUtf8($input)); - } - - /** - * @dataProvider containsPatternProvider - */ - public function testContainsPattern(string $input, string $pattern, bool $expected): void - { - $this->assertSame($expected, $this->traitObject->callContainsPattern($input, $pattern)); - } - - public static function isNotEmptyProvider(): array - { - return [ - 'non empty string' => ['test', true], - 'empty string' => ['', false], - 'spaces only' => [' ', false], - 'tabs and newlines' => ["\t\n", false], - 'zero as string' => ['0', true], - 'with spaces' => [' test ', true], - ]; - } - - public static function isValidUtf8Provider(): array - { - return [ - 'ascii string' => ['Hello World', true], - 'utf8 string' => ['áéíóú', true], - 'emojis' => ['😀👍🎉', true], - 'empty string' => ['', true], - 'valid mixed content' => ['Hello 世界', true], - ]; - } - - public static function containsPatternProvider(): array - { - return [ - 'simple pattern' => ['test123', '/\d+/', true], - 'email pattern' => ['test@example.com', '/^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,}$/', true], - 'no match' => ['abcdef', '/\d+/', false], - 'complex pattern' => ['ABC-123', '/^[A-Z]+-\d+$/', true], - 'empty string' => ['', '/.*/', true], - ]; - } -} diff --git a/tests/Trait/WhitespaceSanitizerTraitTest.php b/tests/Trait/WhitespaceSanitizerTraitTest.php deleted file mode 100644 index 15bda3d..0000000 --- a/tests/Trait/WhitespaceSanitizerTraitTest.php +++ /dev/null @@ -1,99 +0,0 @@ -traitObject = new class { - use WhitespaceSanitizerTrait; - - public function callRemoveAllWhitespace(string $input): string - { - return $this->removeAllWhitespace($input); - } - - public function callNormalizeWhitespace(string $input): string - { - return $this->normalizeWhitespace($input); - } - - public function callTrimWhitespace(string $input): string - { - return $this->trimWhitespace($input); - } - }; - } - - /** - * @dataProvider removeAllWhitespaceProvider - */ - public function testRemoveAllWhitespace(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callRemoveAllWhitespace($input)); - } - - /** - * @dataProvider normalizeWhitespaceProvider - */ - public function testNormalizeWhitespace(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callNormalizeWhitespace($input)); - } - - /** - * @dataProvider trimWhitespaceProvider - */ - public function testTrimWhitespace(string $input, string $expected): void - { - $this->assertSame($expected, $this->traitObject->callTrimWhitespace($input)); - } - - public static function removeAllWhitespaceProvider(): array - { - return [ - 'spaces' => ['hello world', 'helloworld'], - 'tabs and spaces' => ["hello\tworld", 'helloworld'], - 'newlines' => ["hello\nworld", 'helloworld'], - 'multiple spaces' => ['hello world', 'helloworld'], - 'complex whitespace' => ["hello\n\t world", 'helloworld'], - 'empty string' => ['', ''], - 'only whitespace' => [' ', ''], - ]; - } - - public static function normalizeWhitespaceProvider(): array - { - return [ - 'multiple spaces' => ['hello world', 'hello world'], - 'tabs' => ["hello\tworld", 'hello world'], - 'newlines' => ["hello\nworld", 'hello world'], - 'mixed whitespace' => ["hello\n\t world", 'hello world'], - 'empty string' => ['', ''], - 'only whitespace' => [' ', ' '], - 'leading/trailing spaces' => [' hello world ', ' hello world '], - ]; - } - - public static function trimWhitespaceProvider(): array - { - return [ - 'leading spaces' => [' hello', 'hello'], - 'trailing spaces' => ['hello ', 'hello'], - 'both sides' => [' hello ', 'hello'], - 'tabs' => ["\thello\t", 'hello'], - 'newlines' => ["\nhello\n", 'hello'], - 'mixed whitespace' => [" \t\nhello\t \n", 'hello'], - 'empty string' => ['', ''], - 'only whitespace' => [' ', ''], - ]; - } -} diff --git a/tests/Unit/Attribute/AttributeSanitizerTest.php b/tests/Unit/Attribute/AttributeSanitizerTest.php new file mode 100644 index 0000000..7d03151 --- /dev/null +++ b/tests/Unit/Attribute/AttributeSanitizerTest.php @@ -0,0 +1,54 @@ +createAttributeSanitizer(); + $result = $sanitizer->sanitize($dto); + + $this->assertSame('user@test.com', $dto->email); + $this->assertSame('Walmir Silva', $dto->name); + $this->assertSame('no rules', $dto->untouched); + $this->assertTrue($result->wasModified()); + } + + #[Test] + public function testParameterizedAttributeRules(): void + { + $dto = new class () { + #[Sanitize(['truncate', ['max' => 10, 'suffix' => '…']])] + public string $bio = 'This is a very long text'; + }; + + $sanitizer = new SanitizerServiceProvider()->createAttributeSanitizer(); + $sanitizer->sanitize($dto); + + $this->assertSame('This is a…', $dto->bio); + } +} diff --git a/tests/Unit/Core/InMemoryRuleRegistryTest.php b/tests/Unit/Core/InMemoryRuleRegistryTest.php new file mode 100644 index 0000000..698e5ea --- /dev/null +++ b/tests/Unit/Core/InMemoryRuleRegistryTest.php @@ -0,0 +1,48 @@ +register('trim', $rule); + + $this->assertTrue($registry->has('trim')); + $this->assertFalse($registry->has('unknown')); + $this->assertSame($rule, $registry->resolve('trim')); + $this->assertSame(['trim'], $registry->aliases()); + } + + #[Test] + public function testDuplicateAliasThrows(): void + { + $registry = new InMemoryRuleRegistry(); + $registry->register('trim', new TrimRule()); + + $this->expectException(InvalidRuleException::class); + $registry->register('trim', new TrimRule()); + } + + #[Test] + public function testUnknownAliasThrows(): void + { + $registry = new InMemoryRuleRegistry(); + + $this->expectException(InvalidRuleException::class); + $registry->resolve('unknown'); + } +} diff --git a/tests/Unit/Core/SanitizationContextImplTest.php b/tests/Unit/Core/SanitizationContextImplTest.php new file mode 100644 index 0000000..5a1d768 --- /dev/null +++ b/tests/Unit/Core/SanitizationContextImplTest.php @@ -0,0 +1,48 @@ + 1]); + + $this->assertSame('', $ctx->getFieldName()); + $this->assertSame(['a' => 1], $ctx->getRootData()); + $this->assertSame([], $ctx->getParameters()); + } + + #[Test] + public function testWithFieldReturnsNewInstance(): void + { + $ctx = SanitizationContextImpl::create([]); + $ctx2 = $ctx->withField('email'); + + $this->assertSame('', $ctx->getFieldName()); + $this->assertSame('email', $ctx2->getFieldName()); + $this->assertNotSame($ctx, $ctx2); + } + + #[Test] + public function testWithParametersMerges(): void + { + $ctx = SanitizationContextImpl::create([]) + ->withParameters(['a' => 1]) + ->withParameters(['b' => 2]); + + $this->assertSame(1, $ctx->getParameter('a')); + $this->assertSame(2, $ctx->getParameter('b')); + $this->assertNull($ctx->getParameter('c')); + $this->assertSame('default', $ctx->getParameter('c', 'default')); + } +} diff --git a/tests/Unit/Core/SanitizationResultTest.php b/tests/Unit/Core/SanitizationResultTest.php new file mode 100644 index 0000000..30184b0 --- /dev/null +++ b/tests/Unit/Core/SanitizationResultTest.php @@ -0,0 +1,110 @@ + 'WALMIR'], ['name' => 'walmir']); + $this->assertSame(['name' => 'WALMIR'], $result->getOriginalData()); + $this->assertSame(['name' => 'walmir'], $result->getSanitizedData()); + $this->assertSame('walmir', $result->get('name')); + $this->assertNull($result->get('missing')); + } + + #[Test] + public function testWasModified(): void + { + $changed = new SanitizationResult(['x' => 1], ['x' => 2]); + $unchanged = new SanitizationResult(['x' => 1], ['x' => 1]); + $this->assertTrue($changed->wasModified()); + $this->assertFalse($unchanged->wasModified()); + } + + #[Test] + public function testIsFieldModified(): void + { + $result = new SanitizationResult(['x' => 1], ['x' => 2, 'y' => 3]); + $this->assertTrue($result->isFieldModified('x')); + $this->assertTrue($result->isFieldModified('y')); // new key not in original + } + + #[Test] + public function testIsFieldModifiedFalse(): void + { + $result = new SanitizationResult(['x' => 1], ['x' => 1]); + $this->assertFalse($result->isFieldModified('x')); + } + + #[Test] + public function testModifiedFields(): void + { + $result = new SanitizationResult(['a' => 1, 'b' => 2], ['a' => 99, 'b' => 2]); + $this->assertSame(['a'], $result->modifiedFields()); + } + + #[Test] + public function testSetSanitizedValue(): void + { + $result = new SanitizationResult(['x' => 1], ['x' => 1]); + $result->setSanitizedValue('x', 42); + $this->assertSame(42, $result->get('x')); + } + + #[Test] + public function testAddModificationAndGetters(): void + { + $result = new SanitizationResult(['x' => 'A'], ['x' => 'a']); + $mod = new FieldModification('x', 'lower_case', 'A', 'a'); + $result->addModification($mod); + + $this->assertCount(1, $result->getModifications()); + $this->assertSame([$mod], $result->modificationsFor('x')); + $this->assertSame([], $result->modificationsFor('missing')); + $this->assertSame(1, $result->modificationCount()); + } + + #[Test] + public function testModificationCountZeroWhenNotModified(): void + { + $result = new SanitizationResult(['x' => 'same'], ['x' => 'same']); + $result->addModification(new FieldModification('x', 'rule', 'same', 'same')); + $this->assertSame(0, $result->modificationCount()); + } + + #[Test] + public function testMerge(): void + { + $r1 = new SanitizationResult(['a' => 1], ['a' => 2]); + $r1->addModification(new FieldModification('a', 'rule', 1, 2)); + + $r2 = new SanitizationResult(['b' => 3], ['b' => 4]); + $r2->addModification(new FieldModification('b', 'rule', 3, 4)); + + $merged = $r1->merge($r2); + $this->assertSame(['a' => 1, 'b' => 3], $merged->getOriginalData()); + $this->assertSame(['a' => 2, 'b' => 4], $merged->getSanitizedData()); + $this->assertCount(2, $merged->getModifications()); + } + + #[Test] + public function testFieldModificationWasModified(): void + { + $changed = new FieldModification('x', 'rule', 'A', 'a'); + $unchanged = new FieldModification('x', 'rule', 'same', 'same'); + $this->assertTrue($changed->wasModified()); + $this->assertFalse($unchanged->wasModified()); + } +} diff --git a/tests/Unit/Core/SanitizeAttributeHandlerTest.php b/tests/Unit/Core/SanitizeAttributeHandlerTest.php new file mode 100644 index 0000000..fb82ab0 --- /dev/null +++ b/tests/Unit/Core/SanitizeAttributeHandlerTest.php @@ -0,0 +1,97 @@ +handleAttribute('email', $attr, ' TEST@EXAMPLE.COM '); + + $this->assertSame(['email' => ' TEST@EXAMPLE.COM '], $handler->getProcessedPropertyValues()); + $this->assertSame(['email' => ['trim', 'lower_case']], $handler->getFieldRules()); + } + + #[Test] + public function testHandleAttributeMergesMultipleAttributesOnSameProperty(): void + { + $handler = new SanitizeAttributeHandler(); + $attr1 = new Sanitize('trim'); + $attr2 = new Sanitize('capitalize'); + + $handler->handleAttribute('name', $attr1, ' hello '); + $handler->handleAttribute('name', $attr2, ' hello '); + + $this->assertSame(['name' => ['trim', 'capitalize']], $handler->getFieldRules()); + } + + #[Test] + public function testHandleAttributeWithNonSanitizeAttributeReturnsNull(): void + { + $handler = new SanitizeAttributeHandler(); + $nonSanitize = new \stdClass(); + + $result = $handler->handleAttribute('field', $nonSanitize, 'value'); + + $this->assertNull($result); + $this->assertSame([], $handler->getFieldRules()); + } + + #[Test] + public function testGetProcessingResultMessagesReturnsEmpty(): void + { + $handler = new SanitizeAttributeHandler(); + $this->assertSame([], $handler->getProcessingResultMessages()); + } + + #[Test] + public function testGetProcessingResultErrorsReturnsEmpty(): void + { + $handler = new SanitizeAttributeHandler(); + $this->assertSame([], $handler->getProcessingResultErrors()); + } + + #[Test] + public function testSetProcessedValuesAndApplyChanges(): void + { + $handler = new SanitizeAttributeHandler(); + + $obj = new class () { + public string $name = 'original'; + }; + + $handler->setProcessedValues(['name' => 'updated']); + $handler->applyChanges($obj); + + $this->assertSame('updated', $obj->name); + } + + #[Test] + public function testApplyChangesSkipsMissingProperty(): void + { + $handler = new SanitizeAttributeHandler(); + + $obj = new class () { + public string $name = 'original'; + }; + + // 'nonexistent' does not exist on the object — must not throw + $handler->setProcessedValues(['nonexistent' => 'value']); + $handler->applyChanges($obj); + + $this->assertSame('original', $obj->name); + } +} diff --git a/tests/Unit/Core/SanitizerEngineTest.php b/tests/Unit/Core/SanitizerEngineTest.php new file mode 100644 index 0000000..0dedddc --- /dev/null +++ b/tests/Unit/Core/SanitizerEngineTest.php @@ -0,0 +1,179 @@ +createEngine(); + + $result = $engine->sanitize( + ['name' => ' Hello World ', 'email' => ' User@Test.COM '], + [ + 'name' => ['trim'], + 'email' => ['trim', 'lower_case'], + ], + ); + + $this->assertSame('Hello World', $result->get('name')); + $this->assertSame('user@test.com', $result->get('email')); + $this->assertTrue($result->wasModified()); + } + + #[Test] + public function testParameterizedRules(): void + { + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + ['bio' => 'This is a very long biography text for testing'], + ['bio' => [['truncate', ['max' => 20, 'suffix' => '…']]]], + ); + + $this->assertSame('This is a very long…', $result->get('bio')); + } + + #[Test] + public function testModificationTracking(): void + { + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + ['name' => ' Walmir ', 'untouched' => 'hello'], + [ + 'name' => ['trim'], + 'untouched' => ['trim'], + ], + ); + + $this->assertTrue($result->isFieldModified('name')); + $this->assertFalse($result->isFieldModified('untouched')); + $this->assertSame(['name'], $result->modifiedFields()); + $this->assertSame(1, $result->modificationCount()); + } + + #[Test] + public function testDotNotationResolution(): void + { + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + ['user' => ['name' => ' Walmir ']], + ['user.name' => ['trim']], + ); + + $this->assertSame('Walmir', $result->get('user.name')); + } + + #[Test] + public function testPipelineOrdering(): void + { + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + ['tag' => ' Hello World '], + ['tag' => ['trim', 'lower_case', 'slug']], + ); + + $this->assertSame('hello-world', $result->get('tag')); + } + + #[Test] + public function testOriginalDataPreserved(): void + { + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + ['name' => ' test '], + ['name' => ['trim']], + ); + + $this->assertSame(' test ', $result->getOriginalData()['name']); + $this->assertSame('test', $result->getSanitizedData()['name']); + } + + #[Test] + public function testModificationsLog(): void + { + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + ['name' => ' Walmir '], + ['name' => ['trim', 'upper_case']], + ); + + $mods = $result->modificationsFor('name'); + $this->assertCount(2, $mods); + $this->assertSame('string.trim', $mods[0]->ruleName); + $this->assertSame(' Walmir ', $mods[0]->before); + $this->assertSame('Walmir', $mods[0]->after); + $this->assertSame('string.upper_case', $mods[1]->ruleName); + $this->assertSame('WALMIR', $mods[1]->after); + } + + #[Test] + public function testGetConfigurationReturnsDefault(): void + { + $provider = new SanitizerServiceProvider()->createEngine(); + $config = $provider->getConfiguration(); + $this->assertInstanceOf(SanitizerConfiguration::class, $config); + $this->assertTrue($config->trackModifications); + } + + #[Test] + public function testSanitizeWithDirectRuleInstance(): void + { + // Covers the resolveRule($definition instanceof SanitizationRule) branch. + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + ['name' => ' hello '], + ['name' => [new TrimRule()]], + ); + + $this->assertSame('hello', $result->get('name')); + } + + #[Test] + public function testSanitizeWithRuleInstanceInArray(): void + { + // Covers the resolveRule([SanitizationRule, params]) branch (array form with a rule instance). + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + ['name' => ' hello '], + ['name' => [[new TrimRule(), []]]], + ); + + $this->assertSame('hello', $result->get('name')); + } + + #[Test] + public function testResolveValueMissingDotNotationReturnsNull(): void + { + // Covers the null return inside resolveValue when a dot-notation key is missing. + $engine = new SanitizerServiceProvider()->createEngine(); + + $result = $engine->sanitize( + ['user' => ['name' => 'Alice']], + ['user.missing' => ['trim']], + ); + + $this->assertNull($result->get('user.missing')); + } +} diff --git a/tests/Unit/Event/SanitizationEventsTest.php b/tests/Unit/Event/SanitizationEventsTest.php new file mode 100644 index 0000000..61e806a --- /dev/null +++ b/tests/Unit/Event/SanitizationEventsTest.php @@ -0,0 +1,52 @@ +assertSame(['name', 'email'], $event->fields); + $this->assertSame(1.5, $event->timestamp); + } + + #[Test] + public function testSanitizationStartedEventDefaultTimestamp(): void + { + $event = new SanitizationStartedEvent(['name']); + $this->assertSame(0.0, $event->timestamp); + } + + #[Test] + public function testSanitizationCompletedEventStoresResultAndDuration(): void + { + $result = new SanitizationResult(['name' => 'WALMIR'], ['name' => 'walmir']); + $event = new SanitizationCompletedEvent($result, 12.5, 1_000_000.0); + + $this->assertSame($result, $event->result); + $this->assertSame(12.5, $event->durationMs); + $this->assertSame(1_000_000.0, $event->timestamp); + } + + #[Test] + public function testSanitizationCompletedEventDefaultTimestamp(): void + { + $result = new SanitizationResult([], []); + $event = new SanitizationCompletedEvent($result, 0.5); + $this->assertSame(0.0, $event->timestamp); + } +} diff --git a/tests/Unit/Exception/SanitizationExceptionsTest.php b/tests/Unit/Exception/SanitizationExceptionsTest.php new file mode 100644 index 0000000..d015622 --- /dev/null +++ b/tests/Unit/Exception/SanitizationExceptionsTest.php @@ -0,0 +1,53 @@ +assertInstanceOf(InvalidRuleException::class, $e); + $this->assertSame("Sanitization rule alias 'trim' is already registered.", $e->getMessage()); + } + + #[Test] + public function testInvalidRuleExceptionUnknownAlias(): void + { + $e = InvalidRuleException::unknownAlias('unknown'); + + $this->assertInstanceOf(InvalidRuleException::class, $e); + $this->assertSame("Sanitization rule alias 'unknown' is not registered.", $e->getMessage()); + } + + #[Test] + public function testSanitizationExceptionEngineError(): void + { + $e = SanitizationException::engineError('something went wrong'); + + $this->assertInstanceOf(SanitizationException::class, $e); + $this->assertSame('Sanitization engine error: something went wrong', $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + + #[Test] + public function testSanitizationExceptionEngineErrorWithPrevious(): void + { + $previous = new \RuntimeException('root cause'); + $e = SanitizationException::engineError('wrapped', $previous); + + $this->assertSame($previous, $e->getPrevious()); + } +} diff --git a/tests/Unit/Provider/SanitizerServiceProviderTest.php b/tests/Unit/Provider/SanitizerServiceProviderTest.php new file mode 100644 index 0000000..cfe55d3 --- /dev/null +++ b/tests/Unit/Provider/SanitizerServiceProviderTest.php @@ -0,0 +1,61 @@ +createRegistry(); + + $this->assertCount(33, $registry->aliases()); + + foreach (self::EXPECTED_ALIASES as $alias) { + $this->assertTrue($registry->has($alias), "Missing alias: {$alias}"); + } + } + + #[Test] + public function testCreateEngine(): void + { + $engine = new SanitizerServiceProvider()->createEngine(); + $this->assertInstanceOf(SanitizerEngine::class, $engine); + } + + #[Test] + public function testCreateAttributeSanitizer(): void + { + $sanitizer = new SanitizerServiceProvider()->createAttributeSanitizer(); + $this->assertInstanceOf(AttributeSanitizer::class, $sanitizer); + } +} diff --git a/tests/Unit/Rule/Brazilian/BrazilianRulesTest.php b/tests/Unit/Rule/Brazilian/BrazilianRulesTest.php new file mode 100644 index 0000000..73425f9 --- /dev/null +++ b/tests/Unit/Rule/Brazilian/BrazilianRulesTest.php @@ -0,0 +1,115 @@ +withField('test'); + } + + // ── CPF ─────────────────────────────────────────────────────── + + #[Test] + public function testFormatCpfFromDigits(): void + { + $this->assertSame('529.982.247-25', new FormatCpfRule()->sanitize('52998224725', $this->ctx())); + } + + #[Test] + public function testFormatCpfFromFormatted(): void + { + $this->assertSame('529.982.247-25', new FormatCpfRule()->sanitize('529.982.247-25', $this->ctx())); + } + + #[Test] + public function testFormatCpfInvalidLength(): void + { + $this->assertSame('1234', new FormatCpfRule()->sanitize('1234', $this->ctx())); + } + + // ── CNPJ ────────────────────────────────────────────────────── + + #[Test] + public function testFormatCnpjFromDigits(): void + { + $this->assertSame('11.222.333/0001-81', new FormatCnpjRule()->sanitize('11222333000181', $this->ctx())); + } + + #[Test] + public function testFormatCnpjFromFormatted(): void + { + $this->assertSame('11.222.333/0001-81', new FormatCnpjRule()->sanitize('11.222.333/0001-81', $this->ctx())); + } + + #[Test] + public function testFormatCnpjInvalidLength(): void + { + $this->assertSame('123', new FormatCnpjRule()->sanitize('123', $this->ctx())); + } + + // ── CEP ─────────────────────────────────────────────────────── + + #[Test] + public function testFormatCepFromDigits(): void + { + $this->assertSame('63100-000', new FormatCepRule()->sanitize('63100000', $this->ctx())); + } + + #[Test] + public function testFormatCepFromFormatted(): void + { + $this->assertSame('63100-000', new FormatCepRule()->sanitize('63100-000', $this->ctx())); + } + + #[Test] + public function testFormatCepInvalidLength(): void + { + $this->assertSame('123', new FormatCepRule()->sanitize('123', $this->ctx())); + } + + // ── Non-string passthrough ──────────────────────────────────── + + #[Test] + public function testFormatRulesHandleNonString(): void + { + $ctx = $this->ctx(); + $this->assertSame(42, new FormatCpfRule()->sanitize(42, $ctx)); + $this->assertSame(null, new FormatCnpjRule()->sanitize(null, $ctx)); + $this->assertSame([], new FormatCepRule()->sanitize([], $ctx)); + } + + // ── Rule names (constant values — one assertSame per method) ── + + #[Test] + public function testFormatCpfRuleName(): void + { + $this->assertSame('brazilian.format_cpf', new FormatCpfRule()->getName()); + } + + #[Test] + public function testFormatCnpjRuleName(): void + { + $this->assertSame('brazilian.format_cnpj', new FormatCnpjRule()->getName()); + } + + #[Test] + public function testFormatCepRuleName(): void + { + $this->assertSame('brazilian.format_cep', new FormatCepRule()->getName()); + } +} diff --git a/tests/Unit/Rule/Date/DateRulesTest.php b/tests/Unit/Rule/Date/DateRulesTest.php new file mode 100644 index 0000000..3cfe709 --- /dev/null +++ b/tests/Unit/Rule/Date/DateRulesTest.php @@ -0,0 +1,76 @@ +withField('test')->withParameters($params); + } + + #[Test] + public function testNormalizeDateBrToIso(): void + { + $result = new NormalizeDateRule()->sanitize('28/02/2025', $this->ctx(['from' => 'd/m/Y', 'to' => 'Y-m-d'])); + $this->assertSame('2025-02-28', $result); + } + + #[Test] + public function testNormalizeDateInvalidReturnsOriginal(): void + { + $this->assertSame('invalid', new NormalizeDateRule()->sanitize('invalid', $this->ctx())); + } + + #[Test] + public function testNormalizeDateEmptyReturnsOriginal(): void + { + $this->assertSame('', new NormalizeDateRule()->sanitize('', $this->ctx())); + } + + #[Test] + public function testTimestampToDate(): void + { + $result = new TimestampToDateRule()->sanitize(1740700800, $this->ctx(['format' => 'Y-m-d', 'timezone' => 'UTC'])); + $this->assertSame('2025-02-28', $result); + } + + #[Test] + public function testTimestampToDateNonNumeric(): void + { + $this->assertSame('abc', new TimestampToDateRule()->sanitize('abc', $this->ctx())); + } + + #[Test] + public function testTimestampToDateInvalidTimezoneReturnOriginal(): void + { + // An invalid timezone string triggers the catch branch which returns the original value. + $result = new TimestampToDateRule()->sanitize(1740700800, $this->ctx(['format' => 'Y-m-d', 'timezone' => 'Invalid/Zone'])); + $this->assertSame(1740700800, $result); + } + + // ── Rule names (constant values — one assertSame per method) ── + + #[Test] + public function testNormalizeDateRuleName(): void + { + $this->assertSame('date.normalize', new NormalizeDateRule()->getName()); + } + + #[Test] + public function testTimestampToDateRuleName(): void + { + $this->assertSame('date.timestamp_to_date', new TimestampToDateRule()->getName()); + } +} diff --git a/tests/Unit/Rule/Filter/FilterRulesTest.php b/tests/Unit/Rule/Filter/FilterRulesTest.php new file mode 100644 index 0000000..223cb4f --- /dev/null +++ b/tests/Unit/Rule/Filter/FilterRulesTest.php @@ -0,0 +1,100 @@ +withField('test')->withParameters($params); + } + + #[Test] + public function testDigitsOnly(): void + { + $this->assertSame('12345678901', new DigitsOnlyRule()->sanitize('123.456.789-01', $this->ctx())); + } + + #[Test] + public function testAlphaOnly(): void + { + $this->assertSame('SãoPaulo', new AlphaOnlyRule()->sanitize('São Paulo 123!', $this->ctx())); + } + + #[Test] + public function testAlphanumericOnly(): void + { + $this->assertSame('São123', new AlphanumericOnlyRule()->sanitize('São-123!', $this->ctx())); + } + + #[Test] + public function testEmailFilter(): void + { + $this->assertSame('user@test.com', new EmailFilterRule()->sanitize(' User@Test.COM ', $this->ctx())); + } + + #[Test] + public function testDigitsOnlyNonString(): void + { + $this->assertSame(42, new DigitsOnlyRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testAlphaOnlyNonString(): void + { + $this->assertSame(42, new AlphaOnlyRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testAlphanumericOnlyNonString(): void + { + $this->assertSame(42, new AlphanumericOnlyRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testEmailFilterNonString(): void + { + $this->assertSame(42, new EmailFilterRule()->sanitize(42, $this->ctx())); + } + + // ── Rule names (constant values — one assertSame per method) ── + + #[Test] + public function testDigitsOnlyRuleName(): void + { + $this->assertSame('filter.digits_only', new DigitsOnlyRule()->getName()); + } + + #[Test] + public function testAlphaOnlyRuleName(): void + { + $this->assertSame('filter.alpha_only', new AlphaOnlyRule()->getName()); + } + + #[Test] + public function testAlphanumericOnlyRuleName(): void + { + $this->assertSame('filter.alphanumeric_only', new AlphanumericOnlyRule()->getName()); + } + + #[Test] + public function testEmailFilterRuleName(): void + { + $this->assertSame('filter.email', new EmailFilterRule()->getName()); + } +} diff --git a/tests/Unit/Rule/Html/HtmlRulesTest.php b/tests/Unit/Rule/Html/HtmlRulesTest.php new file mode 100644 index 0000000..a76baa0 --- /dev/null +++ b/tests/Unit/Rule/Html/HtmlRulesTest.php @@ -0,0 +1,148 @@ +withField('test')->withParameters($params); + } + + #[Test] + public function testStripTags(): void + { + $this->assertSame('hello', new StripTagsRule()->sanitize('hello', $this->ctx())); + } + + #[Test] + public function testStripTagsWithAllowed(): void + { + $this->assertSame('hellox', new StripTagsRule()->sanitize('hello', $this->ctx(['allowed' => '']))); + } + + #[Test] + public function testHtmlEncode(): void + { + $this->assertSame('<script>', new HtmlEncodeRule()->sanitize(' ', $this->ctx())); + } + + #[Test] + public function testUrlEncode(): void + { + $this->assertSame('hello+world', new UrlEncodeRule()->sanitize('hello world', $this->ctx())); + } + + #[Test] + public function testUrlEncodeRaw(): void + { + $this->assertSame('hello%20world', new UrlEncodeRule()->sanitize('hello world', $this->ctx(['raw' => true]))); + } + + // ── Non-string passthrough ──────────────────────────────────── + + #[Test] + public function testStripTagsNonString(): void + { + $this->assertSame(42, new StripTagsRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testHtmlEncodeNonString(): void + { + $this->assertSame(42, new HtmlEncodeRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testHtmlEncodeDoubleEncodeFalse(): void + { + $result = new HtmlEncodeRule()->sanitize('&', $this->ctx(['double_encode' => false])); + $this->assertSame('&', $result); + } + + #[Test] + public function testHtmlDecodeNonString(): void + { + $this->assertSame(42, new HtmlDecodeRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testHtmlPurifyNonString(): void + { + $this->assertSame(42, new HtmlPurifyRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testUrlEncodeNonString(): void + { + $this->assertSame(42, new UrlEncodeRule()->sanitize(42, $this->ctx())); + } + + // ── Rule names (constant values — one assertSame per method) ── + + #[Test] + public function testStripTagsRuleName(): void + { + $this->assertSame('html.strip_tags', new StripTagsRule()->getName()); + } + + #[Test] + public function testHtmlEncodeRuleName(): void + { + $this->assertSame('html.encode', new HtmlEncodeRule()->getName()); + } + + #[Test] + public function testHtmlDecodeRuleName(): void + { + $this->assertSame('html.decode', new HtmlDecodeRule()->getName()); + } + + #[Test] + public function testHtmlPurifyRuleName(): void + { + $this->assertSame('html.purify', new HtmlPurifyRule()->getName()); + } + + #[Test] + public function testUrlEncodeRuleName(): void + { + $this->assertSame('html.url_encode', new UrlEncodeRule()->getName()); + } +} diff --git a/tests/Unit/Rule/Numeric/NumericRulesTest.php b/tests/Unit/Rule/Numeric/NumericRulesTest.php new file mode 100644 index 0000000..d637ca0 --- /dev/null +++ b/tests/Unit/Rule/Numeric/NumericRulesTest.php @@ -0,0 +1,101 @@ +withField('test')->withParameters($params); + } + + #[Test] + public function testToInt(): void + { + $this->assertSame(42, new ToIntRule()->sanitize('42', $this->ctx())); + $this->assertSame(42, new ToIntRule()->sanitize(42, $this->ctx())); + $this->assertSame('abc', new ToIntRule()->sanitize('abc', $this->ctx())); + } + + #[Test] + public function testToFloat(): void + { + $this->assertSame(3.14, new ToFloatRule()->sanitize('3.14', $this->ctx())); + $this->assertSame(3.14, new ToFloatRule()->sanitize(3.14, $this->ctx())); + $this->assertSame('abc', new ToFloatRule()->sanitize('abc', $this->ctx())); + } + + #[Test] + public function testClamp(): void + { + $this->assertSame(5, new ClampRule()->sanitize(3, $this->ctx(['min' => 5, 'max' => 10]))); + $this->assertSame(7, new ClampRule()->sanitize(7, $this->ctx(['min' => 5, 'max' => 10]))); + $this->assertSame(10, new ClampRule()->sanitize(15, $this->ctx(['min' => 5, 'max' => 10]))); + $this->assertSame('abc', new ClampRule()->sanitize('abc', $this->ctx(['min' => 0]))); + } + + #[Test] + public function testRound(): void + { + $this->assertSame(3.14, new RoundRule()->sanitize(3.14159, $this->ctx(['precision' => 2]))); + } + + #[Test] + public function testRoundCeil(): void + { + $this->assertSame(3.15, new RoundRule()->sanitize(3.141, $this->ctx(['precision' => 2, 'mode' => 'ceil']))); + } + + #[Test] + public function testRoundFloor(): void + { + $this->assertSame(3.14, new RoundRule()->sanitize(3.149, $this->ctx(['precision' => 2, 'mode' => 'floor']))); + } + + #[Test] + public function testRoundNonNumeric(): void + { + $this->assertSame('abc', new RoundRule()->sanitize('abc', $this->ctx())); + } + + // ── Rule names (constant values — one assertSame per method) ── + + #[Test] + public function testToIntRuleName(): void + { + $this->assertSame('numeric.to_int', new ToIntRule()->getName()); + } + + #[Test] + public function testToFloatRuleName(): void + { + $this->assertSame('numeric.to_float', new ToFloatRule()->getName()); + } + + #[Test] + public function testClampRuleName(): void + { + $this->assertSame('numeric.clamp', new ClampRule()->getName()); + } + + #[Test] + public function testRoundRuleName(): void + { + $this->assertSame('numeric.round', new RoundRule()->getName()); + } +} diff --git a/tests/Unit/Rule/String/StringRulesTest.php b/tests/Unit/Rule/String/StringRulesTest.php new file mode 100644 index 0000000..303f566 --- /dev/null +++ b/tests/Unit/Rule/String/StringRulesTest.php @@ -0,0 +1,361 @@ +withField('test')->withParameters($params); + } + + // ── Trim ────────────────────────────────────────────────────── + + #[Test] + public function testTrimDefault(): void + { + $this->assertSame('hello', new TrimRule()->sanitize(' hello ', $this->ctx())); + } + + #[Test] + public function testTrimCustomCharacters(): void + { + $this->assertSame('hello', new TrimRule()->sanitize('xxhelloxx', $this->ctx(['characters' => 'x']))); + } + + #[Test] + public function testTrimNonString(): void + { + $this->assertSame(42, new TrimRule()->sanitize(42, $this->ctx())); + } + + // ── LowerCase ───────────────────────────────────────────────── + + #[Test] + public function testLowerCase(): void + { + $this->assertSame('hello world', new LowerCaseRule()->sanitize('Hello WORLD', $this->ctx())); + } + + #[Test] + public function testLowerCaseUnicode(): void + { + $this->assertSame('são paulo', new LowerCaseRule()->sanitize('SÃO PAULO', $this->ctx())); + } + + // ── UpperCase ───────────────────────────────────────────────── + + #[Test] + public function testUpperCase(): void + { + $this->assertSame('HELLO WORLD', new UpperCaseRule()->sanitize('Hello World', $this->ctx())); + } + + // ── Capitalize ──────────────────────────────────────────────── + + #[Test] + public function testCapitalize(): void + { + $this->assertSame('Hello World', new CapitalizeRule()->sanitize('hello world', $this->ctx())); + } + + #[Test] + public function testCapitalizeUnicode(): void + { + $this->assertSame('São Paulo', new CapitalizeRule()->sanitize('são paulo', $this->ctx())); + } + + // ── Slug ────────────────────────────────────────────────────── + + #[Test] + public function testSlug(): void + { + $this->assertSame('hello-world', new SlugRule()->sanitize('Hello World!', $this->ctx())); + } + + #[Test] + public function testSlugAccented(): void + { + $this->assertSame('sao-paulo-brasil', new SlugRule()->sanitize('São Paulo, Brasil!', $this->ctx())); + } + + #[Test] + public function testSlugCustomSeparator(): void + { + $this->assertSame('hello_world', new SlugRule()->sanitize('Hello World', $this->ctx(['separator' => '_']))); + } + + // ── Truncate ────────────────────────────────────────────────── + + #[Test] + public function testTruncate(): void + { + $this->assertSame('Hello...', new TruncateRule()->sanitize('Hello World', $this->ctx(['max' => 8]))); + } + + #[Test] + public function testTruncateNoTruncationNeeded(): void + { + $this->assertSame('Hi', new TruncateRule()->sanitize('Hi', $this->ctx(['max' => 10]))); + } + + #[Test] + public function testTruncateCustomSuffix(): void + { + $this->assertSame('Hell…', new TruncateRule()->sanitize('Hello World', $this->ctx(['max' => 5, 'suffix' => '…']))); + } + + // ── NormalizeWhitespace ─────────────────────────────────────── + + #[Test] + public function testNormalizeWhitespace(): void + { + $this->assertSame('hello world', new NormalizeWhitespaceRule()->sanitize(" hello \t world ", $this->ctx())); + } + + // ── NormalizeLineEndings ────────────────────────────────────── + + #[Test] + public function testNormalizeLineEndings(): void + { + $this->assertSame("a\nb\nc", new NormalizeLineEndingsRule()->sanitize("a\r\nb\rc", $this->ctx())); + } + + // ── Pad ─────────────────────────────────────────────────────── + + #[Test] + public function testPadRight(): void + { + $this->assertSame('hi ', new PadRule()->sanitize('hi', $this->ctx(['length' => 5]))); + } + + #[Test] + public function testPadLeft(): void + { + $this->assertSame('007', new PadRule()->sanitize('7', $this->ctx(['length' => 3, 'pad' => '0', 'side' => 'left']))); + } + + // ── Replace ─────────────────────────────────────────────────── + + #[Test] + public function testReplace(): void + { + $this->assertSame('Hi World', new ReplaceRule()->sanitize('Hello World', $this->ctx(['search' => 'Hello', 'replace' => 'Hi']))); + } + + #[Test] + public function testReplaceEmptySearch(): void + { + $this->assertSame('Hello', new ReplaceRule()->sanitize('Hello', $this->ctx())); + } + + // ── RegexReplace ────────────────────────────────────────────── + + #[Test] + public function testRegexReplace(): void + { + $result = new RegexReplaceRule()->sanitize('abc123def', $this->ctx(['pattern' => '/\d+/', 'replacement' => '#'])); + $this->assertSame('abc#def', $result); + } + + #[Test] + public function testRegexReplaceEmptyPattern(): void + { + $this->assertSame('test', new RegexReplaceRule()->sanitize('test', $this->ctx())); + } + + // ── StripNonPrintable ───────────────────────────────────────── + + #[Test] + public function testStripNonPrintable(): void + { + $this->assertSame("hello\nworld", new StripNonPrintableRule()->sanitize("hel\x00lo\nwor\x07ld", $this->ctx())); + } + + #[Test] + public function testStripNonPrintablePreservesTabsAndCR(): void + { + $this->assertSame("a\tb\r\nc", new StripNonPrintableRule()->sanitize("a\tb\r\nc", $this->ctx())); + } + + #[Test] + public function testStripNonPrintableNonString(): void + { + $this->assertSame(99, new StripNonPrintableRule()->sanitize(99, $this->ctx())); + } + + // ── Non-string passthrough (each rule must guard !is_string) ── + + #[Test] + public function testLowerCaseNonString(): void + { + $this->assertSame(42, new LowerCaseRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testUpperCaseNonString(): void + { + $this->assertSame(42, new UpperCaseRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testCapitalizeNonString(): void + { + $this->assertSame(42, new CapitalizeRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testSlugNonString(): void + { + $this->assertSame(42, new SlugRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testNormalizeWhitespaceNonString(): void + { + $this->assertSame(42, new NormalizeWhitespaceRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testNormalizeLineEndingsNonString(): void + { + $this->assertSame(42, new NormalizeLineEndingsRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testRegexReplaceNonString(): void + { + $this->assertSame(42, new RegexReplaceRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testReplaceNonString(): void + { + $this->assertSame(42, new ReplaceRule()->sanitize(42, $this->ctx())); + } + + #[Test] + public function testTruncateNonString(): void + { + $this->assertSame(42, new TruncateRule()->sanitize(42, $this->ctx())); + } + + // ── PadRule 'both' side ─────────────────────────────────────── + + #[Test] + public function testPadBoth(): void + { + $this->assertSame('-hi--', new PadRule()->sanitize('hi', $this->ctx(['length' => 5, 'pad' => '-', 'side' => 'both']))); + } + + #[Test] + public function testPadNonString(): void + { + $this->assertSame(42, new PadRule()->sanitize(42, $this->ctx())); + } + + // ── Rule names (constant values — one assertSame per method) ── + + #[Test] + public function testTrimRuleName(): void + { + $this->assertSame('string.trim', new TrimRule()->getName()); + } + + #[Test] + public function testLowerCaseRuleName(): void + { + $this->assertSame('string.lower_case', new LowerCaseRule()->getName()); + } + + #[Test] + public function testUpperCaseRuleName(): void + { + $this->assertSame('string.upper_case', new UpperCaseRule()->getName()); + } + + #[Test] + public function testCapitalizeRuleName(): void + { + $this->assertSame('string.capitalize', new CapitalizeRule()->getName()); + } + + #[Test] + public function testSlugRuleName(): void + { + $this->assertSame('string.slug', new SlugRule()->getName()); + } + + #[Test] + public function testTruncateRuleName(): void + { + $this->assertSame('string.truncate', new TruncateRule()->getName()); + } + + #[Test] + public function testNormalizeWhitespaceRuleName(): void + { + $this->assertSame('string.normalize_whitespace', new NormalizeWhitespaceRule()->getName()); + } + + #[Test] + public function testNormalizeLineEndingsRuleName(): void + { + $this->assertSame('string.normalize_line_endings', new NormalizeLineEndingsRule()->getName()); + } + + #[Test] + public function testPadRuleName(): void + { + $this->assertSame('string.pad', new PadRule()->getName()); + } + + #[Test] + public function testReplaceRuleName(): void + { + $this->assertSame('string.replace', new ReplaceRule()->getName()); + } + + #[Test] + public function testRegexReplaceRuleName(): void + { + $this->assertSame('string.regex_replace', new RegexReplaceRule()->getName()); + } + + #[Test] + public function testStripNonPrintableRuleName(): void + { + $this->assertSame('string.strip_non_printable', new StripNonPrintableRule()->getName()); + } +} diff --git a/tests/Unit/Rule/Type/TypeRulesTest.php b/tests/Unit/Rule/Type/TypeRulesTest.php new file mode 100644 index 0000000..30a5321 --- /dev/null +++ b/tests/Unit/Rule/Type/TypeRulesTest.php @@ -0,0 +1,99 @@ +withField('test')->withParameters($params); + } + + #[Test] + public function testToBool(): void + { + $rule = new ToBoolRule(); + $this->assertTrue($rule->sanitize('true', $this->ctx())); + $this->assertTrue($rule->sanitize('yes', $this->ctx())); + $this->assertTrue($rule->sanitize('1', $this->ctx())); + $this->assertTrue($rule->sanitize('on', $this->ctx())); + $this->assertFalse($rule->sanitize('false', $this->ctx())); + $this->assertFalse($rule->sanitize('no', $this->ctx())); + $this->assertFalse($rule->sanitize('0', $this->ctx())); + $this->assertFalse($rule->sanitize('off', $this->ctx())); + $this->assertFalse($rule->sanitize('', $this->ctx())); + $this->assertSame('maybe', $rule->sanitize('maybe', $this->ctx())); + $this->assertTrue($rule->sanitize(true, $this->ctx())); + } + + #[Test] + public function testToBoolNumericValue(): void + { + $rule = new ToBoolRule(); + $this->assertTrue($rule->sanitize(1, $this->ctx())); + $this->assertFalse($rule->sanitize(0, $this->ctx())); + } + + #[Test] + public function testToBoolNonBoolNonStringNonNumericPassthrough(): void + { + // Covers the final `return $value` branch (line 34) when value is none of bool/string/numeric. + $rule = new ToBoolRule(); + $arr = ['key' => 'value']; + $this->assertSame($arr, $rule->sanitize($arr, $this->ctx())); + } + + #[Test] + public function testToString(): void + { + $rule = new ToStringRule(); + $this->assertSame('42', $rule->sanitize(42, $this->ctx())); + $this->assertSame('3.14', $rule->sanitize(3.14, $this->ctx())); + $this->assertSame('1', $rule->sanitize(true, $this->ctx())); + $this->assertSame('hello', $rule->sanitize('hello', $this->ctx())); + $this->assertSame([1], $rule->sanitize([1], $this->ctx())); // non-scalar passthrough + } + + #[Test] + public function testToArray(): void + { + $rule = new ToArrayRule(); + $this->assertSame([1, 2], $rule->sanitize([1, 2], $this->ctx())); + $this->assertSame(['hello'], $rule->sanitize('hello', $this->ctx())); + $this->assertSame([42], $rule->sanitize(42, $this->ctx())); + $this->assertSame([], $rule->sanitize(null, $this->ctx())); + } + + // ── Rule names (constant values — one assertSame per method) ── + + #[Test] + public function testToBoolRuleName(): void + { + $this->assertSame('type.to_bool', new ToBoolRule()->getName()); + } + + #[Test] + public function testToStringRuleName(): void + { + $this->assertSame('type.to_string', new ToStringRule()->getName()); + } + + #[Test] + public function testToArrayRuleName(): void + { + $this->assertSame('type.to_array', new ToArrayRule()->getName()); + } +} diff --git a/tests/UserProfile.php b/tests/UserProfile.php deleted file mode 100644 index e16a27b..0000000 --- a/tests/UserProfile.php +++ /dev/null @@ -1,342 +0,0 @@ - 'Name was trimmed', - 'html_purifier' => 'HTML was purified in name', - 'xss_sanitizer' => 'XSS attempt was removed from name', - 'html_special_chars' => 'Special characters were escaped in name', - ] - )] - private string $name = ''; - - /** - * User's email address - normalized and trimmed. - */ - #[Sanitize( - processors: ['trim', 'normalize_line_breaks', 'email_sanitizer'], - messages: [ - 'trim' => 'Email was trimmed', - 'normalize_line_breaks' => 'Line breaks in email were normalized', - 'email_sanitizer' => 'Email format was validated and sanitized', - ] - )] - private string $email = ''; - - /** - * User's age - stripped of HTML tags and trimmed. - */ - #[Sanitize( - processors: ['trim', 'strip_tags', 'numeric_sanitizer'], - messages: [ - 'trim' => 'Age was trimmed', - 'strip_tags' => 'HTML tags were removed from age', - 'numeric_sanitizer' => 'Age was validated as numeric', - ] - )] - private string $age = ''; - - /** - * User's biography - sanitized for HTML and markdown content. - */ - #[Sanitize( - processors: ['trim', 'html_purifier', 'markdown'], - messages: [ - 'trim' => 'Bio was trimmed', - 'html_purifier' => 'HTML was purified in bio', - 'markdown' => 'Markdown in bio was processed', - ] - )] - private string $bio = ''; - - /** - * Phone number - normalized and formatted. - */ - #[Sanitize( - processors: ['trim', 'phone_sanitizer'], - messages: [ - 'trim' => 'Phone number was trimmed', - 'phone_sanitizer' => 'Phone number was formatted', - ] - )] - private string $phone = ''; - - /** - * Website URL - validated and sanitized. - */ - #[Sanitize( - processors: ['trim', 'url_sanitizer'], - messages: [ - 'trim' => 'Website URL was trimmed', - 'url_sanitizer' => 'URL was validated and sanitized', - ] - )] - private string $website = ''; - - /** - * Social media handle - alphanumeric sanitization. - */ - #[Sanitize( - processors: ['trim', 'alphanumeric_sanitizer'], - messages: [ - 'trim' => 'Social media handle was trimmed', - 'alphanumeric_sanitizer' => 'Handle was sanitized to alphanumeric', - ] - )] - private string $socialHandle = ''; - - /** - * User's address - sanitized for special characters and line breaks. - */ - #[Sanitize( - processors: ['trim', 'normalize_line_breaks', 'html_special_chars'], - messages: [ - 'trim' => 'Address was trimmed', - 'normalize_line_breaks' => 'Address line breaks were normalized', - 'html_special_chars' => 'Special characters in address were escaped', - ] - )] - private string $address = ''; - - /** - * Constructor initializes default values. - */ - public function __construct() - { - $this->name = ''; - $this->email = ''; - $this->age = ''; - $this->bio = ''; - $this->phone = ''; - $this->website = ''; - $this->socialHandle = ''; - $this->address = ''; - } - - /** - * Get user's name. - */ - public function getName(): string - { - return $this->name; - } - - /** - * Set user's name. - */ - public function setName(string $name): self - { - $this->name = $name; - - return $this; - } - - /** - * Get user's email. - */ - public function getEmail(): string - { - return $this->email; - } - - /** - * Set user's email. - */ - public function setEmail(string $email): self - { - $this->email = $email; - - return $this; - } - - /** - * Get user's age. - */ - public function getAge(): string - { - return $this->age; - } - - /** - * Set user's age. - */ - public function setAge(string $age): self - { - $this->age = $age; - - return $this; - } - - /** - * Get user's biography. - */ - public function getBio(): string - { - return $this->bio; - } - - /** - * Set user's biography. - */ - public function setBio(string $bio): self - { - $this->bio = $bio; - - return $this; - } - - /** - * Get user's phone number. - */ - public function getPhone(): string - { - return $this->phone; - } - - /** - * Set user's phone number. - */ - public function setPhone(string $phone): self - { - $this->phone = $phone; - - return $this; - } - - /** - * Get user's website. - */ - public function getWebsite(): string - { - return $this->website; - } - - /** - * Set user's website. - */ - public function setWebsite(string $website): self - { - $this->website = $website; - - return $this; - } - - /** - * Get user's social media handle. - */ - public function getSocialHandle(): string - { - return $this->socialHandle; - } - - /** - * Set user's social media handle. - */ - public function setSocialHandle(string $socialHandle): self - { - $this->socialHandle = $socialHandle; - - return $this; - } - - /** - * Get user's address. - */ - public function getAddress(): string - { - return $this->address; - } - - /** - * Set user's address. - */ - public function setAddress(string $address): self - { - $this->address = $address; - - return $this; - } - - /** - * Convert user profile to array. - */ - public function toArray(): array - { - return [ - 'name' => $this->name, - 'email' => $this->email, - 'age' => $this->age, - 'bio' => $this->bio, - 'phone' => $this->phone, - 'website' => $this->website, - 'socialHandle' => $this->socialHandle, - 'address' => $this->address, - ]; - } - - /** - * Create UserProfile from array. - */ - public static function fromArray(array $data): self - { - $profile = new self(); - - if (isset($data['name'])) { - $profile->setName($data['name']); - } - if (isset($data['email'])) { - $profile->setEmail($data['email']); - } - if (isset($data['age'])) { - $profile->setAge($data['age']); - } - if (isset($data['bio'])) { - $profile->setBio($data['bio']); - } - if (isset($data['phone'])) { - $profile->setPhone($data['phone']); - } - if (isset($data['website'])) { - $profile->setWebsite($data['website']); - } - if (isset($data['socialHandle'])) { - $profile->setSocialHandle($data['socialHandle']); - } - if (isset($data['address'])) { - $profile->setAddress($data['address']); - } - - return $profile; - } - - /** - * Create string representation of UserProfile. - */ - public function __toString(): string - { - return sprintf( - "UserProfile(name='%s', email='%s', age='%s')", - $this->name, - $this->email, - $this->age - ); - } -} diff --git a/tests/application.php b/tests/application.php deleted file mode 100644 index 1caaba8..0000000 --- a/tests/application.php +++ /dev/null @@ -1,416 +0,0 @@ - [ - 'allowedTags' => [], - 'allowedAttributes' => [], - ], - 'xss_sanitizer', - ] - )] - private string $fullName = ''; - - #[Sanitize( - processors: [ - 'trim', - 'email_sanitizer' => [ - 'removeMailtoPrefix' => true, - 'typoReplacements' => [ - '@gmail.con' => '@gmail.com', - '@yaho.com' => '@yahoo.com', - ], - ], - ] - )] - private string $email = ''; - - #[Sanitize( - processors: [ - 'trim', - 'phone_sanitizer' => [ - 'applyFormat' => true, - 'format' => '(##) #####-####', - 'placeholder' => '#', - ], - ] - )] - private string $phone = ''; - - #[Sanitize( - processors: [ - 'trim', - 'html_purifier' => [ - 'allowedTags' => ['h2', 'p', 'ul', 'li', 'a'], - 'allowedAttributes' => ['href' => ['a']], - ], - ] - )] - private string $professionalSummary = ''; - - #[Sanitize( - processors: [ - 'trim', - 'numeric_sanitizer' => [ - 'allowDecimal' => false, - 'allowNegative' => false, - ], - ] - )] - private string $yearsOfExperience = ''; - - #[Sanitize( - processors: [ - 'trim', - 'url_sanitizer' => [ - 'enforceProtocol' => true, - 'defaultProtocol' => 'https://', - 'removeTrailingSlash' => true, - ], - ] - )] - private string $portfolioUrl = ''; - - #[Sanitize( - processors: [ - 'trim', - 'alphanumeric_sanitizer' => [ - 'allowUnderscore' => true, - 'allowDash' => true, - 'preserveCase' => false, - ], - ] - )] - private string $githubHandle = ''; - - #[Sanitize( - processors: [ - 'trim', - 'alphanumeric_sanitizer' => [ - 'allowUnderscore' => true, - 'allowDash' => false, - 'preserveCase' => false, - ], - ] - )] - private string $linkedinHandle = ''; - - #[Sanitize( - processors: [ - 'trim', - 'filename_sanitizer' => [ - 'replacement' => '-', - 'preserveExtension' => true, - 'allowedChars' => ['a-z', 'A-Z', '0-9', '-', '_', ' '], - ], - ] - )] - private string $resumeFileName = ''; - - #[Sanitize( - processors: [ - 'trim', - 'json_sanitizer', - ] - )] - private string $projectsJson = ''; - - #[Sanitize( - processors: [ - 'trim', - 'sql_injection' => [ - 'escapeMap' => [ - "'" => "\\'", - '"' => '\\"', - ], - ], - ] - )] - private string $searchQuery = ''; - - #[Sanitize( - processors: [ - 'trim', - 'strip_tags' => [ - 'allowedTags' => ['p', 'br'], - 'keepSafeAttributes' => true, - ], - ] - )] - private string $plainTextContent = ''; - - // Getters and Setters - public function getFullName(): string - { - return $this->fullName; - } - - public function setFullName(string $value): self - { - $this->fullName = $value; - - return $this; - } - - public function getEmail(): string - { - return $this->email; - } - - public function setEmail(string $value): self - { - $this->email = $value; - - return $this; - } - - public function getPhone(): string - { - return $this->phone; - } - - public function setPhone(string $value): self - { - $this->phone = $value; - - return $this; - } - - public function getProfessionalSummary(): string - { - return $this->professionalSummary; - } - - public function setProfessionalSummary(string $value): self - { - $this->professionalSummary = $value; - - return $this; - } - - public function getYearsOfExperience(): string - { - return $this->yearsOfExperience; - } - - public function setYearsOfExperience(string $value): self - { - $this->yearsOfExperience = $value; - - return $this; - } - - public function getPortfolioUrl(): string - { - return $this->portfolioUrl; - } - - public function setPortfolioUrl(string $value): self - { - $this->portfolioUrl = $value; - - return $this; - } - - public function getGithubHandle(): string - { - return $this->githubHandle; - } - - public function setGithubHandle(string $value): self - { - $this->githubHandle = $value; - - return $this; - } - - public function getLinkedinHandle(): string - { - return $this->linkedinHandle; - } - - public function setLinkedinHandle(string $value): self - { - $this->linkedinHandle = $value; - - return $this; - } - - public function getResumeFileName(): string - { - return $this->resumeFileName; - } - - public function setResumeFileName(string $value): self - { - $this->resumeFileName = $value; - - return $this; - } - - public function getProjectsJson(): string - { - return $this->projectsJson; - } - - public function setProjectsJson(string $value): self - { - $this->projectsJson = $value; - - return $this; - } - - public function getSearchQuery(): string - { - return $this->searchQuery; - } - - public function setSearchQuery(string $value): self - { - $this->searchQuery = $value; - - return $this; - } - - public function getPlainTextContent(): string - { - return $this->plainTextContent; - } - - public function setPlainTextContent(string $value): self - { - $this->plainTextContent = $value; - - return $this; - } -} - -// Create and configure the registry -$registry = new ProcessorRegistry(); - -// Register all required processors -$registry->register('sanitizer', 'trim', new TrimSanitizer()); -$registry->register('sanitizer', 'html_special_chars', new HtmlSpecialCharsSanitizer()); -$registry->register('sanitizer', 'normalize_line_breaks', new NormalizeLineBreaksSanitizer()); -$registry->register('sanitizer', 'html_purifier', new HtmlPurifierSanitizer()); -$registry->register('sanitizer', 'markdown', new MarkdownSanitizer()); -$registry->register('sanitizer', 'numeric_sanitizer', new NumericSanitizer()); -$registry->register('sanitizer', 'email_sanitizer', new EmailSanitizer()); -$registry->register('sanitizer', 'phone_sanitizer', new PhoneSanitizer()); -$registry->register('sanitizer', 'url_sanitizer', new UrlSanitizer()); -$registry->register('sanitizer', 'alphanumeric_sanitizer', new AlphanumericSanitizer()); -$registry->register('sanitizer', 'filename_sanitizer', new FilenameSanitizer()); -$registry->register('sanitizer', 'json_sanitizer', new JsonSanitizer()); -$registry->register('sanitizer', 'xss_sanitizer', new XssSanitizer()); -$registry->register('sanitizer', 'sql_injection', new SqlInjectionSanitizer()); -$registry->register('sanitizer', 'strip_tags', new StripTagsSanitizer()); - -// Create the sanitizer -$sanitizer = new Sanitizer($registry); - -// Create an application with potentially dangerous data -$application = new JobApplication(); -$application - ->setFullName(" Walmir Silva ") - ->setEmail(" walmir.silva@gmail.con \n") - ->setPhone('11987654321') - ->setProfessionalSummary(" -

Professional Summary

- -

I am a senior developer with experience in:

- -
    -
  • PHP Development
  • -
  • Database Design
  • -
  • System Architecture
  • -
- -

Visit my website: My Portfolio

-") - ->setYearsOfExperience('10') - ->setPortfolioUrl('example.com/portfolio') - ->setGithubHandle('@walmir-silva') - ->setLinkedinHandle('Walmir-Silva') - ->setResumeFileName('Walmir Silva Resume (2024).pdf') - ->setProjectsJson('{ - "projects": [ - { - "name": "E-commerce Platform", - "role": "Lead Developer", - "duration": "2 years" - } - ] - }') - ->setProjectsJson('{ - "projects": [ - { - "name": "E-commerce Platform", - "role": "Lead Developer", - "duration": "2 years" - } - ] - }') - ->setSearchQuery("SELECT * FROM users'; DROP TABLE users; --") - ->setPlainTextContent('

Este é um texto com algumas tags HTML que precisam ser tratadas

'); - -// Function to display the results - -function displayResults(JobApplication $application, array $result): void -{ - echo "Job Application Sanitization Results:\n"; - echo "=====================================\n\n"; - - echo "Sanitized Values:\n"; - echo "----------------\n"; - - // Display all sanitized values with clear formatting - echo sprintf("Full Name: %s\n", $application->getFullName()); - echo sprintf("Email: %s\n", $application->getEmail()); - echo sprintf("Phone: %s\n", $application->getPhone()); - echo sprintf("Years of Experience: %s\n", $application->getYearsOfExperience()); - echo sprintf("Portfolio URL: %s\n", $application->getPortfolioUrl()); - echo sprintf("GitHub Handle: %s\n", $application->getGithubHandle()); - echo sprintf("LinkedIn Handle: %s\n", $application->getLinkedinHandle()); - echo sprintf("Resume Filename: %s\n", $application->getResumeFileName()); - // Adicionar os novos campos aqui - echo sprintf("Search Query: %s\n", $application->getSearchQuery()); - echo sprintf("Plain Text Content: %s\n", $application->getPlainTextContent()); - - echo "\nProfessional Summary:\n"; - echo "-------------------\n"; - echo $application->getProfessionalSummary() . "\n\n"; - - echo "Projects JSON:\n"; - echo "-------------\n"; - echo $application->getProjectsJson() . "\n"; -} - -// Sanitize the application and display results -$result = $sanitizer->sanitize($application); -displayResults($application, $result->toArray()); diff --git a/tests/post.php b/tests/post.php deleted file mode 100644 index 68480ff..0000000 --- a/tests/post.php +++ /dev/null @@ -1,184 +0,0 @@ - 'Title was trimmed', - 'html_special_chars' => 'Special characters in title were escaped', - 'xss_sanitizer' => 'XSS attempt was removed from title', - ] - )] - private string $title = ''; - - #[Sanitize( - processors: ['trim', 'normalize_line_breaks'], - messages: [ - 'trim' => 'Slug was trimmed', - 'normalize_line_breaks' => 'Line breaks in slug were normalized', - ] - )] - private string $slug = ''; - - #[Sanitize( - processors: ['trim', 'markdown', 'html_purifier'], - messages: [ - 'trim' => 'Content was trimmed', - 'markdown' => 'Markdown in content was processed', - 'html_purifier' => 'HTML in content was purified', - ] - )] - private string $content = ''; - - #[Sanitize( - processors: ['trim', 'strip_tags', 'html_special_chars'], - messages: [ - 'trim' => 'Author name was trimmed', - 'strip_tags' => 'HTML tags were removed from author name', - 'html_special_chars' => 'Special characters in author name were escaped', - ] - )] - private string $authorName = ''; - - // Getters and setters... - - public function getTitle(): string - { - return $this->title; - } - - public function setTitle(string $title): void - { - $this->title = $title; - } - - public function getSlug(): string - { - return $this->slug; - } - - public function setSlug(string $slug): void - { - $this->slug = $slug; - } - - public function getContent(): string - { - return $this->content; - } - - public function setContent(string $content): void - { - $this->content = $content; - } - - public function getAuthorName(): string - { - return $this->authorName; - } - - public function setAuthorName(string $authorName): void - { - $this->authorName = $authorName; - } -} - -// Set up the sanitizer -$registry = new ProcessorRegistry(); -$registry->register('sanitizer', 'trim', new TrimSanitizer()); -$registry->register('sanitizer', 'html_special_chars', new HtmlSpecialCharsSanitizer()); -$registry->register('sanitizer', 'normalize_line_breaks', new NormalizeLineBreaksSanitizer()); -$registry->register('sanitizer', 'strip_tags', new StripTagsSanitizer()); -$registry->register('sanitizer', 'markdown', new MarkdownSanitizer()); -$registry->register('sanitizer', 'xss_sanitizer', new XssSanitizer()); - -// Configure HTML Purifier with specific settings for blog content -$htmlPurifier = new HtmlPurifierSanitizer(); -$htmlPurifier->configure([ - 'allowedTags' => ['p', 'br', 'strong', 'em', 'u', 'ol', 'ul', 'li', 'a', 'img', 'h2', 'h3', 'blockquote'], - 'allowedAttributes' => ['href' => ['a'], 'src' => ['img'], 'alt' => ['img']], -]); -$registry->register('sanitizer', 'html_purifier', $htmlPurifier); - -$sanitizer = new Sanitizer($registry); - -// Simulating form submission with potentially unsafe data -$blogPost = new BlogPost(); -$blogPost->setTitle(" Exploring KaririCode: A Modern PHP Framework "); -$blogPost->setSlug(" exploring-kariricode-a-modern-php-framework \r\n"); -$blogPost->setContent(" -# Introduction - -KaririCode is a **powerful** and _flexible_ PHP framework designed for modern web development. - - - -## Key Features - -1. Robust sanitization -2. Efficient routing -3. Powerful ORM - -Check out our [official website](https://kariricode.org) for more information! - - -"); - -$blogPost->setAuthorName("John Doe "); - -// Function to display original and sanitized values -function displayBlogPost(BlogPost $post, Sanitizer $sanitizer) -{ - echo "Original Blog Post:\n"; - echo "Title: \"{$post->getTitle()}\"\n"; - echo "Slug: \"{$post->getSlug()}\"\n"; - echo "Content: \"{$post->getContent()}\"\n"; - echo "Author: \"{$post->getAuthorName()}\"\n\n"; - - $result = $sanitizer->sanitize($post); - - echo "Sanitized Blog Post:\n"; - echo "Title: \"{$result['object']->getTitle()}\"\n"; - echo "Slug: \"{$result['object']->getSlug()}\"\n"; - echo "Content: \"{$result['object']->getContent()}\"\n"; - echo "Author: \"{$result['object']->getAuthorName()}\"\n\n"; - - if (!empty($result['sanitizedValues'])) { - echo "Sanitization Details:\n"; - foreach ($result['sanitizedValues'] as $property => $data) { - echo ucfirst($property) . ":\n"; - foreach ($data['messages'] as $processorName => $message) { - echo " - [$processorName] $message\n"; - } - } - } - - if (!empty($result['errors'])) { - echo "\nErrors:\n"; - foreach ($result['errors'] as $property => $errors) { - echo ucfirst($property) . ":\n"; - foreach ($errors as $error) { - echo " - $error\n"; - } - } - } -} - -// Display and sanitize the blog post -displayBlogPost($blogPost, $sanitizer);