allowedExtensionsMap = $allowedExtensions !== null ? array_flip($allowedExtensions) : null; $this->blockedExtensionsMap = $blockedExtensions !== null ? array_flip($blockedExtensions) : null; // Compile path traversal patterns: 6 str_contains() → 1 regex $this->pathTraversalPattern = '#(?:\.\./)' . '|(?:\.\.\\\\)' . '|(?:%2e%2e/)' . '|(?:%2e%2e\\\\)' . '|(?:\.\.%2f)' . '|(?:\.\.%5c)#i'; } /** * Factory für Standard-Validator mit sicheren Defaults */ public static function createDefault(): self { return new self( allowedExtensions: null, // Keine Einschränkung blockedExtensions: ['exe', 'bat', 'sh', 'cmd', 'com'], // Executable files blockieren maxFileSize: FileSize::fromMegabytes(100), // 100MB max baseDirectory: null ); } /** * Factory für strict Validator (nur whitelisted extensions) */ public static function createStrict(array $allowedExtensions): self { return new self( allowedExtensions: $allowedExtensions, blockedExtensions: null, maxFileSize: FileSize::fromMegabytes(50), baseDirectory: null ); } /** * Factory für Upload-Validator */ public static function forUploads(?FileSize $maxSize = null): self { return new self( allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'csv', 'json'], blockedExtensions: ['exe', 'bat', 'sh', 'cmd', 'com', 'php', 'phtml'], maxFileSize: $maxSize ?? FileSize::fromMegabytes(10), baseDirectory: null ); } /** * Factory für Image-Validator */ public static function forImages(?FileSize $maxSize = null): self { return new self( allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'], blockedExtensions: null, maxFileSize: $maxSize ?? FileSize::fromMegabytes(5), baseDirectory: null ); } /** * Validiert Dateipfad * * @throws FileValidationException */ public function validatePath(string $path): void { // Empty path if (empty($path)) { throw FileValidationException::invalidPath( $path, 'File path cannot be empty' ); } // Null bytes (security) if (str_contains($path, "\0")) { throw FileValidationException::invalidPath( $path, 'File path contains null bytes' ); } // Path traversal attempt if ($this->containsPathTraversal($path)) { throw FileValidationException::invalidPath( $path, 'Path traversal attempt detected', FilesystemErrorCode::PATH_TRAVERSAL_ATTEMPT ); } // Base directory constraint if ($this->baseDirectory !== null) { $realPath = realpath($path); $realBase = realpath($this->baseDirectory); if ($realPath === false || $realBase === false) { throw FileValidationException::invalidPath( $path, 'Cannot resolve real path' ); } if (!str_starts_with($realPath, $realBase)) { throw FileValidationException::invalidPath( $realPath, 'Path is outside base directory', FilesystemErrorCode::PATH_TRAVERSAL_ATTEMPT ); } } } /** * Validiert Dateiendung * * @throws FileValidationException */ public function validateExtension(string $path): void { $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if (empty($extension)) { // Keine Extension ist ok wenn keine Whitelist definiert if ($this->allowedExtensions !== null) { throw FileValidationException::invalidPath( $path, 'File has no extension' ); } return; } // Whitelist check - O(1) lookup mit array_flip if ($this->allowedExtensionsMap !== null) { if (!isset($this->allowedExtensionsMap[$extension])) { throw FileValidationException::invalidExtension( $path, $extension, $this->allowedExtensions ); } } // Blacklist check - O(1) lookup mit array_flip if ($this->blockedExtensionsMap !== null) { if (isset($this->blockedExtensionsMap[$extension])) { throw FileValidationException::blockedExtension( $path, $extension, $this->blockedExtensions ); } } } /** * Validiert Dateigröße * * @throws FileValidationException */ public function validateFileSize(FileSize $fileSize): void { if ($this->maxFileSize === null) { return; } if ($fileSize->exceedsLimit($this->maxFileSize)) { throw FileValidationException::fileSizeExceeded( $fileSize->toHumanReadable(), $this->maxFileSize->toHumanReadable(), $fileSize->toBytes(), $this->maxFileSize->toBytes() ); } } /** * Validiert ob Datei existiert * * @throws FileNotFoundException */ public function validateExists(string $path): void { if (!file_exists($path)) { throw new FileNotFoundException($path); } } /** * Validiert ob Pfad beschreibbar ist * * @throws FilePermissionException */ public function validateWritable(string $path): void { if (!is_writable($path)) { throw FilePermissionException::write($path, 'Path is not writable'); } } /** * Validiert ob Pfad lesbar ist * * @throws FilePermissionException */ public function validateReadable(string $path): void { if (!is_readable($path)) { throw FilePermissionException::read($path, 'Path is not readable'); } } /** * Vollständige Validierung für File-Upload * * @throws FilesystemException */ public function validateUpload(string $path, FileSize $fileSize): void { $this->validatePath($path); $this->validateExtension($path); $this->validateFileSize($fileSize); } /** * Vollständige Validierung für File-Read * * @throws FilesystemException */ public function validateRead(string $path): void { $this->validatePath($path); $this->validateExists($path); $this->validateReadable($path); } /** * Vollständige Validierung für File-Write * * @throws FileValidationException * @throws DirectoryCreateException * @throws FilePermissionException */ public function validateWrite(string $path, ?FileSize $fileSize = null): void { $this->validatePath($path); $this->validateExtension($path); if ($fileSize !== null) { $this->validateFileSize($fileSize); } $directory = dirname($path); if (!is_dir($directory)) { throw new DirectoryCreateException($directory); } $this->validateWritable($directory); } /** * Prüft auf Path Traversal Patterns * * Performance: Compiled regex (1 call) statt 6 str_contains() Calls */ private function containsPathTraversal(string $path): bool { return preg_match($this->pathTraversalPattern, $path) === 1; } /** * Gibt erlaubte Extensions zurück * * @return array|null */ public function getAllowedExtensions(): ?array { return $this->allowedExtensions; } /** * Gibt blockierte Extensions zurück * * @return array|null */ public function getBlockedExtensions(): ?array { return $this->blockedExtensions; } /** * Gibt maximale Dateigröße zurück */ public function getMaxFileSize(): ?FileSize { return $this->maxFileSize; } /** * Prüft ob Extension erlaubt ist (ohne Exception) */ public function isExtensionAllowed(string $extension): bool { $extension = strtolower(ltrim($extension, '.')); // O(1) lookup mit optimierten Maps if ($this->allowedExtensionsMap !== null) { return isset($this->allowedExtensionsMap[$extension]); } if ($this->blockedExtensionsMap !== null) { return !isset($this->blockedExtensionsMap[$extension]); } return true; // Keine Einschränkungen } }