diff --git a/docs/queue.rst b/docs/queue.rst index 809f2be..2498087 100644 --- a/docs/queue.rst +++ b/docs/queue.rst @@ -11,6 +11,13 @@ Brief capabilities: * Process jobs with a handler callback. * Track ``pending``, ``processing``, and ``failed`` buckets. * Return queue statistics via ``stats()``. +* Coordinate concurrent local readers and writers with file locks. + +Queue job IDs use cryptographically secure random values. Invalid queue JSON is +reported as an error instead of being silently replaced, and ``maxJobs`` limits +all attempted jobs, including failures. Flysystem-backed queues retain portable +read/write behavior, but their storage adapter must provide any cross-process +coordination required by the application. Good fit: diff --git a/src/DirectoryManager/DirectoryOperations.php b/src/DirectoryManager/DirectoryOperations.php index 1d908d2..59a1b6a 100644 --- a/src/DirectoryManager/DirectoryOperations.php +++ b/src/DirectoryManager/DirectoryOperations.php @@ -124,8 +124,10 @@ public function create(int $permissions = 0755, bool $recursive = true): bool */ public function createTempDir(): string { - $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('temp_', true); - FlysystemHelper::createDirectory($tempDir); + $tempDir = PathHelper::createTempDirectory('pathwise_'); + if (!is_string($tempDir)) { + throw new DirectoryOperationException('Unable to create temporary directory.'); + } return $tempDir; } @@ -148,7 +150,7 @@ public function delete(bool $recursive = false): bool return true; } - if (FlysystemHelper::listContents($this->path, false) !== []) { + foreach (FlysystemHelper::listContentsListing($this->path, false) as $_item) { return false; } diff --git a/src/FileManager/Concerns/FileCompressionArchiveConcern.php b/src/FileManager/Concerns/FileCompressionArchiveConcern.php index 489c446..1cfe376 100644 --- a/src/FileManager/Concerns/FileCompressionArchiveConcern.php +++ b/src/FileManager/Concerns/FileCompressionArchiveConcern.php @@ -298,12 +298,12 @@ private function countFilesForCompression(string $source, array $extensions = [] private function createExtractionTempDirectory(): string { - $extractTempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_extract_', true); - if (!$this->runSilently(static fn(): bool => mkdir($extractTempDir, 0755, true)) && !is_dir($extractTempDir)) { - throw new CompressionException("Unable to create extraction directory: {$extractTempDir}"); + $extractTempDir = PathHelper::createTempDirectory('pathwise_extract_'); + if (!is_string($extractTempDir)) { + throw new CompressionException('Unable to create extraction directory.'); } - return PathHelper::normalize($extractTempDir); + return $extractTempDir; } private function emitDecompressionProgress(): void diff --git a/src/FileManager/Concerns/FsConcern.php b/src/FileManager/Concerns/FsConcern.php index 8ba1191..b1991de 100644 --- a/src/FileManager/Concerns/FsConcern.php +++ b/src/FileManager/Concerns/FsConcern.php @@ -83,8 +83,8 @@ private function doLocalizeCompressionSource(string $source, ?string &$cleanupPa private function doLocalizeRemoteDirectorySource(string $normalizedSource, string $originalSource): string { - $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pathwise_src_dir_', true); - if (!$this->doRunSilently(static fn(): bool => mkdir($tempDir, 0755, true)) && !is_dir($tempDir)) { + $tempDir = PathHelper::createTempDirectory('pathwise_src_dir_'); + if (!is_string($tempDir)) { throw new CompressionException("Unable to localize source path: {$originalSource}"); } @@ -112,18 +112,24 @@ private function doLocalizeRemoteFileSource(string $normalizedSource, string $or return PathHelper::normalize($tempFile); } + /** @param list $patterns */ + private function doMatchesAnyPattern(array $patterns, string $relativePath): bool + { + return array_any($patterns, fn($pattern) => fnmatch($pattern, $relativePath)); + } + private function doMaterializeDirectoryToLocal(string $sourcePath, string $localDirectory): void { $base = $this->doResolveMaterializationBase($sourcePath); - foreach (FlysystemHelper::listContents($sourcePath, true) as $item) { + foreach (FlysystemHelper::listContentsListing($sourcePath, true) as $item) { $relative = $this->doResolveMaterializedRelativePath($item, $base); if ($relative === null) { continue; } $localTarget = PathHelper::join($localDirectory, $relative); - if (($item['type'] ?? null) === 'dir') { + if ($item->isDir()) { $this->doEnsureLocalDirectoryExists($localTarget); continue; @@ -208,10 +214,8 @@ private function doShouldIncludePath(string $relativePath): bool } } - return array_all( - array_merge($this->excludePatterns, $this->ignorePatterns), - fn($pattern) => !fnmatch($pattern, $relativePath), - ); + return !$this->doMatchesAnyPattern($this->excludePatterns, $relativePath) + && !$this->doMatchesAnyPattern($this->ignorePatterns, $relativePath); } private function doShouldTraverseDirectory(string $relativePath): bool @@ -221,13 +225,15 @@ private function doShouldTraverseDirectory(string $relativePath): bool return true; } - foreach (array_merge($this->excludePatterns, $this->ignorePatterns) as $pattern) { - $pattern = trim((string) $pattern); - if ($pattern === '') { - continue; - } - if (fnmatch(rtrim($pattern, '/'), $normalized) || fnmatch(rtrim($pattern, '/') . '/*', $normalized . '/x')) { - return false; + foreach ([$this->excludePatterns, $this->ignorePatterns] as $patterns) { + foreach ($patterns as $pattern) { + $pattern = trim((string) $pattern); + if ($pattern === '') { + continue; + } + if (fnmatch(rtrim($pattern, '/'), $normalized) || fnmatch(rtrim($pattern, '/') . '/*', $normalized . '/x')) { + return false; + } } } diff --git a/src/Indexing/ChecksumIndexer.php b/src/Indexing/ChecksumIndexer.php index f715083..08b7b4c 100644 --- a/src/Indexing/ChecksumIndexer.php +++ b/src/Indexing/ChecksumIndexer.php @@ -55,21 +55,32 @@ public static function deduplicateWithHardLinks(string $directory, string $algor foreach ($duplicates as $paths) { $canonical = array_shift($paths); + if (!is_string($canonical) || !self::isLocalFile($canonical)) { + array_push($skipped, ...$paths); + + continue; + } + foreach ($paths as $path) { - if (!is_string($canonical) || !self::isLocalFile($canonical) || !self::isLocalFile($path)) { + if (!self::isLocalFile($path)) { $skipped[] = $path; continue; } - $tmp = $path . '.tmp_delete'; + $tmp = self::temporarySiblingPath($path); + if ($tmp === null) { + $skipped[] = $path; + + continue; + } if (!self::runSilently(static fn(): bool => rename($path, $tmp))) { $skipped[] = $path; continue; } - if (self::runSilently(static fn(): bool => link($canonical, $path))) { + if (self::filesAreIdentical($canonical, $tmp) && self::runSilently(static fn(): bool => link($canonical, $path))) { self::unlinkSilently($tmp); $linked[] = $path; } else { @@ -99,6 +110,43 @@ public static function findDuplicates(string $directory, string $algorithm = 'sh return array_filter($index, static fn(array $paths): bool => count($paths) > 1); } + private static function filesAreIdentical(string $firstPath, string $secondPath): bool + { + $firstSize = filesize($firstPath); + $secondSize = filesize($secondPath); + if (!is_int($firstSize) || !is_int($secondSize) || $firstSize !== $secondSize) { + return false; + } + + $first = fopen($firstPath, 'rb'); + $second = fopen($secondPath, 'rb'); + if (!is_resource($first) || !is_resource($second)) { + if (is_resource($first)) { + fclose($first); + } + if (is_resource($second)) { + fclose($second); + } + + return false; + } + + try { + while (!feof($first) && !feof($second)) { + $firstChunk = fread($first, 65536); + $secondChunk = fread($second, 65536); + if (!is_string($firstChunk) || !is_string($secondChunk) || $firstChunk !== $secondChunk) { + return false; + } + } + + return feof($first) && feof($second); + } finally { + fclose($first); + fclose($second); + } + } + private static function hashPath(string $path, string $algorithm): ?string { if (self::isLocalFile($path)) { @@ -127,49 +175,39 @@ private static function isLocalFile(string $path): bool return !PathHelper::hasScheme($path) && is_file($path); } - /** - * @return list - */ - private static function iterFiles(string $directory): array + /** @return \Generator */ + private static function iterFiles(string $directory): \Generator { if (PathHelper::hasScheme($directory) || (FlysystemHelper::hasDefaultFilesystem() && !PathHelper::isAbsolute($directory))) { - return self::iterFilesViaFlysystem($directory); + yield from self::iterFilesViaFlysystem($directory); + + return; } - return self::iterFilesLocal($directory); + yield from self::iterFilesLocal($directory); } - /** - * @return list - */ - private static function iterFilesLocal(string $directory): array + /** @return \Generator */ + private static function iterFilesLocal(string $directory): \Generator { - $paths = []; foreach (LocalFileIterator::files($directory) as $item) { - $paths[] = $item->getPathname(); + yield $item->getPathname(); } - - return $paths; } - /** - * @return list - */ - private static function iterFilesViaFlysystem(string $directory): array + /** @return \Generator */ + private static function iterFilesViaFlysystem(string $directory): \Generator { - $paths = []; $base = FlysystemPathResolver::resolveDirectoryBase($directory); - foreach (FlysystemHelper::listContents($directory, true) as $item) { + foreach (FlysystemHelper::listContentsListing($directory, true) as $item) { $relative = FlysystemPathResolver::relativePathFromItem($item, $base, 'file'); if ($relative === null) { continue; } - $paths[] = PathHelper::join($directory, $relative); + yield PathHelper::join($directory, $relative); } - - return $paths; } private static function runSilently(callable $operation): mixed @@ -183,6 +221,18 @@ private static function runSilently(callable $operation): mixed } } + private static function temporarySiblingPath(string $path): ?string + { + for ($attempt = 0; $attempt < 10; $attempt++) { + $temporaryPath = $path . '.pathwise_' . bin2hex(random_bytes(16)); + if (!file_exists($temporaryPath)) { + return $temporaryPath; + } + } + + return null; + } + private static function unlinkSilently(string $path): void { if (!is_file($path)) { diff --git a/src/Observability/AuditTrail.php b/src/Observability/AuditTrail.php index e02f6e0..477352f 100644 --- a/src/Observability/AuditTrail.php +++ b/src/Observability/AuditTrail.php @@ -7,6 +7,7 @@ use DateTimeInterface; use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; +use RuntimeException; final readonly class AuditTrail { @@ -42,10 +43,23 @@ public function log(string $operation, array $context = []): void 'context' => $context, ]; - $existing = FlysystemHelper::fileExists($this->logFilePath) - ? FlysystemHelper::read($this->logFilePath) - : ''; + $line = json_encode($record, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . PHP_EOL; + if ($this->isLocalLogPath()) { + $written = file_put_contents($this->logFilePath, $line, FILE_APPEND | LOCK_EX); + if ($written !== strlen($line)) { + throw new RuntimeException("Unable to append audit record to {$this->logFilePath}."); + } - FlysystemHelper::write($this->logFilePath, $existing . json_encode($record) . PHP_EOL); + return; + } + + $existing = FlysystemHelper::fileExists($this->logFilePath) ? FlysystemHelper::read($this->logFilePath) : ''; + FlysystemHelper::write($this->logFilePath, $existing . $line); + } + + private function isLocalLogPath(): bool + { + return !PathHelper::hasScheme($this->logFilePath) + && (PathHelper::isAbsolute($this->logFilePath) || !FlysystemHelper::hasDefaultFilesystem()); } } diff --git a/src/Queue/FileJobQueue.php b/src/Queue/FileJobQueue.php index 7facbc5..9940f37 100644 --- a/src/Queue/FileJobQueue.php +++ b/src/Queue/FileJobQueue.php @@ -6,6 +6,9 @@ use Infocyph\Pathwise\Utils\FlysystemHelper; use Infocyph\Pathwise\Utils\PathHelper; +use InvalidArgumentException; +use JsonException; +use RuntimeException; /** * @phpstan-type QueueJob array{ @@ -31,8 +34,10 @@ public function __construct(private string $queueFilePath) if (!FlysystemHelper::directoryExists($directory)) { FlysystemHelper::createDirectory($directory); } - if (!FlysystemHelper::fileExists($this->queueFilePath)) { - FlysystemHelper::write($this->queueFilePath, (string) json_encode(['pending' => [], 'processing' => [], 'failed' => []], JSON_PRETTY_PRINT)); + if ($this->isLocalQueuePath()) { + $this->initializeLocalQueue(); + } elseif (!FlysystemHelper::fileExists($this->queueFilePath)) { + FlysystemHelper::write($this->queueFilePath, $this->encodeQueueData($this->emptyQueueData())); } } @@ -46,20 +51,25 @@ public function __construct(private string $queueFilePath) */ public function enqueue(string $type, array $payload = [], int $priority = 0): string { - $jobId = uniqid('job_', true); - $data = $this->readQueueData(); - $data['pending'][] = [ - 'id' => $jobId, - 'type' => $type, - 'payload' => $payload, - 'priority' => $priority, - 'createdAt' => time(), - ]; + if (trim($type) === '') { + throw new InvalidArgumentException('Queue job type must not be empty.'); + } + + $jobId = 'job_' . bin2hex(random_bytes(16)); - usort($data['pending'], static fn(array $a, array $b): int => $b['priority'] <=> $a['priority']); - $this->writeQueueData($data); + return $this->mutateQueueData(static function (array $data) use ($jobId, $type, $payload, $priority): array { + $data['pending'][] = [ + 'id' => $jobId, + 'type' => $type, + 'payload' => $payload, + 'priority' => $priority, + 'createdAt' => time(), + ]; - return $jobId; + usort($data['pending'], static fn(array $a, array $b): int => $b['priority'] <=> $a['priority']); + + return [$data, $jobId]; + }); } /** @@ -71,41 +81,31 @@ public function enqueue(string $type, array $payload = [], int $priority = 0): s */ public function process(callable $handler, int $maxJobs = 0): array { + if ($maxJobs < 0) { + throw new InvalidArgumentException('maxJobs must be greater than or equal to zero.'); + } + $processed = 0; $failed = 0; + $attempted = 0; - while (true) { - $data = $this->readQueueData(); - if ($data['pending'] === []) { - break; - } - if ($maxJobs > 0 && $processed >= $maxJobs) { + while ($maxJobs === 0 || $attempted < $maxJobs) { + $job = $this->claimNextJob(); + if ($job === null) { break; } - - $job = array_shift($data['pending']); - $data['processing'][] = $job; - $this->writeQueueData($data); + $attempted++; try { $handler($job); $processed++; } catch (\Throwable $e) { - $job['error'] = $e->getMessage(); + $job['error'] = substr($e->getMessage(), 0, 4096); $job['failedAt'] = time(); $failed++; } - $data = $this->readQueueData(); - $data['processing'] = array_values(array_filter( - $data['processing'], - static fn(array $processingJob): bool => $processingJob['id'] !== $job['id'], - )); - - if (isset($job['error'])) { - $data['failed'][] = $job; - } - $this->writeQueueData($data); + $this->completeJob($job); } return [ @@ -131,6 +131,165 @@ public function stats(): array ]; } + /** + * @return QueueJob|null + */ + private function claimNextJob(): ?array + { + return $this->mutateQueueData(static function (array $data): array { + if ($data['pending'] === []) { + return [$data, null]; + } + + $job = array_shift($data['pending']); + $data['processing'][] = $job; + + return [$data, $job]; + }); + } + + /** + * @param QueueJob $job + */ + private function completeJob(array $job): void + { + $this->mutateQueueData(static function (array $data) use ($job): array { + foreach ($data['processing'] as $index => $processingJob) { + if ($processingJob['id'] !== $job['id']) { + continue; + } + + array_splice($data['processing'], $index, 1); + + break; + } + + if (isset($job['error'])) { + $data['failed'][] = $job; + } + + return [$data, null]; + }); + } + + /** + * @return QueueState + */ + private function decodeQueueData(string $content): array + { + if ($content === '') { + return $this->emptyQueueData(); + } + + try { + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new RuntimeException("Queue file contains invalid JSON: {$this->queueFilePath}", 0, $exception); + } + + if (!is_array($decoded)) { + throw new RuntimeException("Queue file does not contain an object: {$this->queueFilePath}"); + } + + return [ + 'pending' => $this->normalizeJobList($decoded['pending'] ?? []), + 'processing' => $this->normalizeJobList($decoded['processing'] ?? []), + 'failed' => $this->normalizeJobList($decoded['failed'] ?? []), + ]; + } + + /** + * @return QueueState + */ + private function emptyQueueData(): array + { + return ['pending' => [], 'processing' => [], 'failed' => []]; + } + + /** + * @param QueueState $data + */ + private function encodeQueueData(array $data): string + { + return json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + private function initializeLocalQueue(): void + { + $stream = fopen($this->queueFilePath, 'c+b'); + if (!is_resource($stream)) { + throw new RuntimeException("Unable to initialize queue file: {$this->queueFilePath}"); + } + + try { + if (!flock($stream, LOCK_EX)) { + throw new RuntimeException("Unable to lock queue file: {$this->queueFilePath}"); + } + + $metadata = fstat($stream); + if (!is_array($metadata) || $metadata['size'] !== 0) { + return; + } + + $this->writeFully($stream, $this->encodeQueueData($this->emptyQueueData())); + fflush($stream); + } finally { + flock($stream, LOCK_UN); + fclose($stream); + } + } + + private function isLocalQueuePath(): bool + { + return !PathHelper::hasScheme($this->queueFilePath) + && (PathHelper::isAbsolute($this->queueFilePath) || !FlysystemHelper::hasDefaultFilesystem()); + } + + /** + * @template T + * @param callable(QueueState): array{0: QueueState, 1: T} $mutation + * @return T + */ + private function mutateQueueData(callable $mutation): mixed + { + if (!$this->isLocalQueuePath()) { + [$data, $result] = $mutation($this->readQueueData()); + $this->writeQueueData($data); + + return $result; + } + + $stream = fopen($this->queueFilePath, 'c+b'); + if (!is_resource($stream)) { + throw new RuntimeException("Unable to open queue file: {$this->queueFilePath}"); + } + + try { + if (!flock($stream, LOCK_EX)) { + throw new RuntimeException("Unable to lock queue file: {$this->queueFilePath}"); + } + + rewind($stream); + $content = stream_get_contents($stream); + [$data, $result] = $mutation($this->decodeQueueData(is_string($content) ? $content : '')); + $encoded = $this->encodeQueueData($data); + + rewind($stream); + if (!ftruncate($stream, 0)) { + throw new RuntimeException("Unable to truncate queue file: {$this->queueFilePath}"); + } + $this->writeFully($stream, $encoded); + if (!fflush($stream)) { + throw new RuntimeException("Unable to flush queue file: {$this->queueFilePath}"); + } + + return $result; + } finally { + flock($stream, LOCK_UN); + fclose($stream); + } + } + /** * @return QueueJob|null */ @@ -223,24 +382,47 @@ private function normalizePayload(mixed $value): array private function readQueueData(): array { if (!FlysystemHelper::fileExists($this->queueFilePath)) { - return ['pending' => [], 'processing' => [], 'failed' => []]; + return $this->emptyQueueData(); } - $content = FlysystemHelper::read($this->queueFilePath); - if ($content === '') { - return ['pending' => [], 'processing' => [], 'failed' => []]; + if (!$this->isLocalQueuePath()) { + return $this->decodeQueueData(FlysystemHelper::read($this->queueFilePath)); } - $decoded = json_decode($content, true); - if (!is_array($decoded)) { - return ['pending' => [], 'processing' => [], 'failed' => []]; + $stream = fopen($this->queueFilePath, 'rb'); + if (!is_resource($stream)) { + throw new RuntimeException("Unable to open queue file: {$this->queueFilePath}"); } - return [ - 'pending' => $this->normalizeJobList($decoded['pending'] ?? []), - 'processing' => $this->normalizeJobList($decoded['processing'] ?? []), - 'failed' => $this->normalizeJobList($decoded['failed'] ?? []), - ]; + try { + if (!flock($stream, LOCK_SH)) { + throw new RuntimeException("Unable to lock queue file: {$this->queueFilePath}"); + } + + $content = stream_get_contents($stream); + + return $this->decodeQueueData(is_string($content) ? $content : ''); + } finally { + flock($stream, LOCK_UN); + fclose($stream); + } + } + + private function writeFully(mixed $stream, string $contents): void + { + if (!is_resource($stream)) { + throw new RuntimeException('Invalid queue stream.'); + } + + $offset = 0; + $length = strlen($contents); + while ($offset < $length) { + $written = fwrite($stream, substr($contents, $offset)); + if (!is_int($written) || $written < 1) { + throw new RuntimeException("Unable to write queue file: {$this->queueFilePath}"); + } + $offset += $written; + } } /** @@ -248,6 +430,6 @@ private function readQueueData(): array */ private function writeQueueData(array $data): void { - FlysystemHelper::write($this->queueFilePath, (string) json_encode($data, JSON_PRETTY_PRINT)); + FlysystemHelper::write($this->queueFilePath, $this->encodeQueueData($data)); } } diff --git a/src/Retention/RetentionManager.php b/src/Retention/RetentionManager.php index 4a65c6c..bf6b1ca 100644 --- a/src/Retention/RetentionManager.php +++ b/src/Retention/RetentionManager.php @@ -8,6 +8,7 @@ use Infocyph\Pathwise\Utils\FlysystemPathResolver; use Infocyph\Pathwise\Utils\LocalFileIterator; use Infocyph\Pathwise\Utils\PathHelper; +use InvalidArgumentException; final class RetentionManager { @@ -26,13 +27,15 @@ public static function apply( ?int $maxAgeDays = null, string $sortBy = 'mtime', ): array { + self::validateOptions($keepLast, $maxAgeDays, $sortBy); + $directory = PathHelper::normalize($directory); if (!FlysystemHelper::directoryExists($directory)) { return ['deleted' => [], 'kept' => []]; } $files = self::collectFiles($directory); - usort($files, fn(array $a, array $b): int => ($b[$sortBy] ?? 0) <=> ($a[$sortBy] ?? 0)); + usort($files, static fn(array $a, array $b): int => $b[$sortBy] <=> $a[$sortBy]); $kept = []; $deleted = []; @@ -94,7 +97,7 @@ private static function collectFilesViaFlysystem(string $directory): array $files = []; $base = FlysystemPathResolver::resolveDirectoryBase($directory); - foreach (FlysystemHelper::listContents($directory, true) as $item) { + foreach (FlysystemHelper::listContentsListing($directory, true) as $item) { $entry = self::normalizeFlysystemEntry($directory, $base, $item); if ($entry === null) { continue; @@ -107,17 +110,16 @@ private static function collectFilesViaFlysystem(string $directory): array } /** - * @param array $item * @return array{path: string, mtime: int, ctime: int}|null */ - private static function normalizeFlysystemEntry(string $directory, string $base, array $item): ?array + private static function normalizeFlysystemEntry(string $directory, string $base, \League\Flysystem\StorageAttributes $item): ?array { $relative = FlysystemPathResolver::relativePathFromItem($item, $base, 'file'); if ($relative === null) { return null; } - $mtime = FlysystemPathResolver::intFromMixed($item['last_modified'] ?? 0); + $mtime = $item->lastModified() ?? 0; return [ 'path' => PathHelper::join($directory, $relative), @@ -125,4 +127,17 @@ private static function normalizeFlysystemEntry(string $directory, string $base, 'ctime' => $mtime, ]; } + + private static function validateOptions(?int $keepLast, ?int $maxAgeDays, string $sortBy): void + { + if ($keepLast !== null && $keepLast < 0) { + throw new InvalidArgumentException('keepLast must be null or greater than or equal to zero.'); + } + if ($maxAgeDays !== null && $maxAgeDays < 0) { + throw new InvalidArgumentException('maxAgeDays must be null or greater than or equal to zero.'); + } + if ($sortBy !== 'mtime' && $sortBy !== 'ctime') { + throw new InvalidArgumentException("Unsupported retention sort field: {$sortBy}."); + } + } } diff --git a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php index 3454c29..a09f8f5 100644 --- a/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php +++ b/src/StreamHandler/Concerns/UploadProcessorValidationConcern.php @@ -279,13 +279,11 @@ private function validateContentTypeIntegrity(string $filePath, string $fileType $this->validateMagicSignatureForExtension($filePath, $normalizedExtension); } - /** - * Validate the uploaded file. - */ /** * @param array $file + * @return array{error: int, size: int, tmp_name: string, name: string} */ - private function validateFile(array $file): void + private function validateFile(array $file): array { $error = $file['error'] ?? null; if (!is_int($error)) { @@ -311,7 +309,15 @@ private function validateFile(array $file): void throw new UploadException('Invalid file upload parameters.'); } - $this->validateFileSize($this->normalizeUploadSize($size)); + $normalizedSize = $this->normalizeUploadSize($size); + $this->validateFileSize($normalizedSize); + + return [ + 'error' => $error, + 'size' => $normalizedSize, + 'tmp_name' => $tmpName, + 'name' => $name, + ]; } private function validateFileExtension(string $extension): void diff --git a/src/StreamHandler/DownloadProcessor.php b/src/StreamHandler/DownloadProcessor.php index 495f4aa..45b153f 100644 --- a/src/StreamHandler/DownloadProcessor.php +++ b/src/StreamHandler/DownloadProcessor.php @@ -290,7 +290,7 @@ private function buildContentDisposition(string $disposition, string $fileName): private function buildEtag(string $path, int $size, int $lastModified): string { - $fingerprint = substr(hash('sha1', $path), 0, 8); + $fingerprint = hash('sha256', $path); return sprintf('W/"%x-%x-%s"', $size, $lastModified, $fingerprint); } diff --git a/src/StreamHandler/UploadProcessor.php b/src/StreamHandler/UploadProcessor.php index 90c665e..1b31415 100644 --- a/src/StreamHandler/UploadProcessor.php +++ b/src/StreamHandler/UploadProcessor.php @@ -120,7 +120,7 @@ class UploadProcessor */ public function finalizeChunkUpload(string $uploadId): string { - if (empty($this->uploadDir)) { + if (!isset($this->uploadDir) || $this->uploadDir === '') { throw new UploadException('Upload directory is not set.'); } @@ -178,7 +178,7 @@ public function getValidationProfiles(): array /** * Process an upload chunk and persist resumable state. * - * @param UploadInput $chunkFile The chunk file data from $_FILES. + * @param array $chunkFile The chunk file data from $_FILES. * @param string $uploadId The unique upload identifier. * @param int $chunkIndex The index of this chunk (0-based). * @param int $totalChunks Total number of chunks expected. @@ -188,11 +188,11 @@ public function getValidationProfiles(): array */ public function processChunkUpload(array $chunkFile, string $uploadId, int $chunkIndex, int $totalChunks, string $originalFilename): array { - if (empty($this->uploadDir)) { + if (!isset($this->uploadDir) || $this->uploadDir === '') { throw new UploadException('Upload directory is not set.'); } + $chunkFile = $this->validateFile($chunkFile); $this->validateChunkUploadRequest($chunkFile, $uploadId, $chunkIndex, $totalChunks, $originalFilename); - $this->validateFile($chunkFile); $chunkDirectory = $this->getChunkDirectory($uploadId); if (!FlysystemHelper::directoryExists($chunkDirectory)) { @@ -228,18 +228,20 @@ public function processChunkUpload(array $chunkFile, string $uploadId, int $chun /** * Process the upload and save the file. * - * @param UploadInput $file The file data from $_FILES. + * @param array $file The file data from $_FILES. * @return string The path to the saved file. * @throws UploadException If validation fails or upload directory is not set. */ public function processUpload(array $file): string { + $logFileName = is_string($file['name'] ?? null) ? $file['name'] : null; + try { - if (empty($this->uploadDir)) { + if (!isset($this->uploadDir) || $this->uploadDir === '') { throw new UploadException('Upload directory is not set.'); } - $this->validateFile($file); + $file = $this->validateFile($file); $tmpName = $file['tmp_name']; $extension = pathinfo($file['name'], PATHINFO_EXTENSION); $fileType = $this->validateUploadedPayload($tmpName, $extension, false); @@ -284,7 +286,7 @@ public function processUpload(array $file): string if (isset($this->logger)) { $this->logger->error('File upload failed.', [ 'error' => $e->getMessage(), - 'file' => $file['name'], + 'file' => $logFileName, ]); } @@ -443,28 +445,18 @@ public function setValidationSettings(array $allowedFileTypes, int $maxFileSize) */ private function generateFileName(?string $dataSource, string $extension): string { - $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); - $callingClass = $backtrace[1]['class'] ?? null; - $callingMethod = $backtrace[1]['function'] ?? null; - - $shortClass = is_string($callingClass) - ? (strrchr($callingClass, '\\') !== false ? substr(strrchr($callingClass, '\\'), 1) : $callingClass) - : 'Upload'; - $classPrefix = strtolower(preg_replace('/[^A-Za-z0-9]/', '', $shortClass) ?: 'upload'); - - $methodName = is_string($callingMethod) ? $callingMethod : 'process'; - $methodPrefix = strtolower(preg_replace('/[^A-Za-z0-9]/', '', $methodName) ?: 'process'); - - $prefix = "{$classPrefix}_$methodPrefix"; - - $hash = match ($this->namingStrategy) { - 'timestamp' => time(), - default => $dataSource ? sha1_file($dataSource) : sha1(uniqid('', true)), + $identifier = match ($this->namingStrategy) { + 'timestamp' => sprintf('%d_%s', time(), bin2hex(random_bytes(8))), + default => $dataSource !== null ? hash_file('sha256', $dataSource) : bin2hex(random_bytes(32)), }; + if (!is_string($identifier)) { + throw new UploadException('Unable to generate upload file name.'); + } + $extension = ltrim($extension, '.'); return $extension !== '' - ? sprintf('%s_%s.%s', $prefix, $hash, $extension) - : sprintf('%s_%s', $prefix, $hash); + ? sprintf('upload_%s.%s', $identifier, $extension) + : sprintf('upload_%s', $identifier); } } diff --git a/src/Utils/FileWatcher.php b/src/Utils/FileWatcher.php index 80912f7..ca814cc 100644 --- a/src/Utils/FileWatcher.php +++ b/src/Utils/FileWatcher.php @@ -162,15 +162,15 @@ private static function snapshotViaFlysystem(string $path, bool $recursive): arr $entries = []; $base = FlysystemPathResolver::resolveDirectoryBase($path); - foreach (FlysystemHelper::listContents($path, $recursive) as $item) { + foreach (FlysystemHelper::listContentsListing($path, $recursive) as $item) { $relative = FlysystemPathResolver::relativePathFromItem($item, $base, 'file'); if ($relative === null) { continue; } $resolved = PathHelper::join($path, $relative); - $lastModified = FlysystemPathResolver::intFromMixed($item['last_modified'] ?? 0); - $fileSize = FlysystemPathResolver::intFromMixed($item['file_size'] ?? 0); + $lastModified = $item->lastModified() ?? 0; + $fileSize = $item instanceof \League\Flysystem\FileAttributes ? ($item->fileSize() ?? 0) : 0; $entries[$resolved] = [ 'mtime' => $lastModified, diff --git a/src/Utils/FlysystemPathResolver.php b/src/Utils/FlysystemPathResolver.php index 0b670d1..12f4300 100644 --- a/src/Utils/FlysystemPathResolver.php +++ b/src/Utils/FlysystemPathResolver.php @@ -4,6 +4,8 @@ namespace Infocyph\Pathwise\Utils; +use League\Flysystem\StorageAttributes; + final class FlysystemPathResolver { public static function intFromMixed(mixed $value, int $default = 0): int @@ -17,6 +19,14 @@ public static function intFromMixed(mixed $value, int $default = 0): int public static function relativePathFromItem(mixed $item, string $base, ?string $requiredType = null): ?string { + if ($item instanceof StorageAttributes) { + if ($requiredType !== null && $item->type() !== $requiredType) { + return null; + } + + return self::relativePathFromRawPath($item->path(), $base); + } + if (!is_array($item)) { return null; } diff --git a/src/Utils/MetadataHelper.php b/src/Utils/MetadataHelper.php index 3e85e84..365f781 100644 --- a/src/Utils/MetadataHelper.php +++ b/src/Utils/MetadataHelper.php @@ -63,7 +63,7 @@ public static function getAllMetadata(string $path, bool $humanReadableSize = fa * @return string|null The checksum of the file, or null if the path is not * a file or if the algorithm is not supported. */ - public static function getChecksum(string $path, string $algorithm = 'md5'): ?string + public static function getChecksum(string $path, string $algorithm = 'sha256'): ?string { if (!FlysystemHelper::fileExists($path) || !in_array($algorithm, hash_algos(), true)) { return null; @@ -88,12 +88,13 @@ public static function getDirectorySize(string $directory): ?int } $size = 0; - foreach (FlysystemHelper::listContents($directory, true) as $item) { - if (($item['type'] ?? null) !== 'file') { + foreach (FlysystemHelper::listContentsListing($directory, true) as $item) { + if (!$item->isFile()) { continue; } - $size += self::intFromMixed($item['file_size'] ?? 0); + $fileSize = $item instanceof \League\Flysystem\FileAttributes ? $item->fileSize() : null; + $size += $fileSize ?? 0; } return $size; @@ -122,8 +123,8 @@ public static function getFileCount(string $directory, bool $recursive = true): } $count = 0; - foreach (FlysystemHelper::listContents($directory, $recursive) as $item) { - if (($item['type'] ?? null) === 'file') { + foreach (FlysystemHelper::listContentsListing($directory, $recursive) as $item) { + if ($item->isFile()) { $count++; } } @@ -179,7 +180,7 @@ public static function getFileSize(string $path, bool $humanReadable = false): s public static function getHumanReadableTimestamps(string $path): ?array { $timestamps = self::getTimestamps($path); - if (!$timestamps) { + if ($timestamps === null) { return null; } @@ -377,7 +378,9 @@ public static function isBrokenSymlink(string $path): ?bool */ public static function isHidden(string $path): bool { - return basename($path)[0] === '.'; + $basename = basename($path); + + return $basename !== '' && str_starts_with($basename, '.'); } /** @@ -404,13 +407,4 @@ private static function getOwnershipResolver(): OwnershipResolverInterface { return self::$ownershipResolver ??= OwnershipResolverFactory::create(); } - - private static function intFromMixed(mixed $value): int - { - if (is_int($value)) { - return $value; - } - - return is_numeric($value) ? (int) $value : 0; - } } diff --git a/src/Utils/PathHelper.php b/src/Utils/PathHelper.php index 81da0ca..9e330c2 100644 --- a/src/Utils/PathHelper.php +++ b/src/Utils/PathHelper.php @@ -6,6 +6,8 @@ class PathHelper { + private const int NORMALIZATION_CACHE_LIMIT = 1024; + /** @var array */ private static array $cache = []; @@ -70,9 +72,23 @@ public static function createDirectory(string $path, int $permissions = 0755): b */ public static function createTempDirectory(string $prefix = 'temp_'): string|false { - $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $prefix . uniqid(); + $safePrefix = preg_replace('/[^A-Za-z0-9_.-]/', '_', $prefix) ?? 'temp_'; + $safePrefix = substr($safePrefix !== '' ? $safePrefix : 'temp_', 0, 32); + + set_error_handler(static fn(): bool => true); - return mkdir($tempDir) ? $tempDir : false; + try { + for ($attempt = 0; $attempt < 10; $attempt++) { + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $safePrefix . bin2hex(random_bytes(16)); + if (mkdir($tempDir, 0700)) { + return self::normalize($tempDir); + } + } + } finally { + restore_error_handler(); + } + + return false; } /** @@ -83,7 +99,9 @@ public static function createTempDirectory(string $prefix = 'temp_'): string|fal */ public static function createTempFile(string $prefix = 'temp_'): string|false { - return tempnam(sys_get_temp_dir(), $prefix); + $safePrefix = preg_replace('/[^A-Za-z0-9_.-]/', '_', $prefix) ?? 'temp_'; + + return tempnam(sys_get_temp_dir(), substr($safePrefix !== '' ? $safePrefix : 'temp_', 0, 32)); } /** @@ -256,9 +274,16 @@ public static function join(string ...$segments): string */ public static function normalize(string $path): string { - $originalPath = $path; + if (isset(self::$cache[$path])) { + return self::$cache[$path]; + } + + $normalized = self::normalizeUncached($path); + if (count(self::$cache) >= self::NORMALIZATION_CACHE_LIMIT) { + self::$cache = []; + } - return self::$cache[$originalPath] ?? self::$cache[$originalPath] = self::normalizeUncached($path); + return self::$cache[$path] = $normalized; } /** diff --git a/src/Utils/PermissionsHelper.php b/src/Utils/PermissionsHelper.php index 6a292df..9dffcbc 100644 --- a/src/Utils/PermissionsHelper.php +++ b/src/Utils/PermissionsHelper.php @@ -8,8 +8,7 @@ class PermissionsHelper { - /** @var array */ - private static array $permissionCache = []; + private const array PERMISSION_TRIPLETS = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']; /** * Checks if the specified path is executable. @@ -69,22 +68,21 @@ public static function canWrite(string $path): bool */ public static function formatPermissions(int $permissions): string { - $flags = [ - // Owner permissions - 0x0100 => 'r', - 0x0080 => 'w', - 0x0040 => ($permissions & 0x0800) ? 's' : 'x', - // Group permissions - 0x0020 => 'r', - 0x0010 => 'w', - 0x0008 => ($permissions & 0x0400) ? 's' : 'x', - // Others permissions - 0x0004 => 'r', - 0x0002 => 'w', - 0x0001 => ($permissions & 0x0200) ? 't' : 'x', - ]; + $owner = self::PERMISSION_TRIPLETS[($permissions >> 6) & 7]; + $group = self::PERMISSION_TRIPLETS[($permissions >> 3) & 7]; + $other = self::PERMISSION_TRIPLETS[$permissions & 7]; - return array_reduce(array_keys($flags), fn($info, $flag) => $info . (($permissions & $flag) ? $flags[$flag] : '-'), ''); + if (($permissions & 0x0800) !== 0) { + $owner[2] = $owner[2] === 'x' ? 's' : 'S'; + } + if (($permissions & 0x0400) !== 0) { + $group[2] = $group[2] === 'x' ? 's' : 'S'; + } + if (($permissions & 0x0200) !== 0) { + $other[2] = $other[2] === 'x' ? 't' : 'T'; + } + + return $owner . $group . $other; } /** @@ -178,16 +176,12 @@ public static function getPermissions(string $path): ?string return null; } - if (!isset(self::$permissionCache[$path])) { - $permissions = fileperms($path); - if (!is_int($permissions)) { - return null; - } - - self::$permissionCache[$path] = substr(sprintf('%04o', $permissions), -4); + $permissions = fileperms($path); + if (!is_int($permissions)) { + return null; } - return self::$permissionCache[$path]; + return substr(sprintf('%04o', $permissions), -4); } /** @@ -216,7 +210,7 @@ public static function setOwnership(string $path, string $owner, ?string $group } $result = chown($path, $owner); - if ($group) { + if ($group !== null && $group !== '') { $result = $result && chgrp($path, $group); } diff --git a/src/functions.php b/src/functions.php index e81380e..19d54e8 100644 --- a/src/functions.php +++ b/src/functions.php @@ -24,6 +24,7 @@ function getHumanReadableFileSize(int $sizeInBytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $power = $sizeInBytes > 0 ? (int) floor(log($sizeInBytes, 1024)) : 0; + $power = min($power, count($units) - 1); return number_format($sizeInBytes / (1024 ** $power), 2) . ' ' . $units[$power]; } @@ -42,9 +43,11 @@ function isDirectoryEmpty(string $directoryPath): bool if (!$isLocalDirectory && !FlysystemHelper::directoryExists($directoryPath)) { throw new InvalidArgumentException('The provided path is not a directory.'); } - $contents = FlysystemHelper::listContents($directoryPath, false); + foreach (FlysystemHelper::listContentsListing($directoryPath, false) as $_item) { + return false; + } - return count($contents) === 0; + return true; } } @@ -143,17 +146,15 @@ function listFiles(string $directoryPath): array if (!$isLocalDirectory && !FlysystemHelper::directoryExists($directoryPath)) { throw new InvalidArgumentException('The provided path is not a directory.'); } - $items = FlysystemHelper::listContents($directoryPath, false); $files = []; - foreach ($items as $item) { - $type = $item['type'] ?? null; - if (!is_string($type) || $type !== 'file') { + foreach (FlysystemHelper::listContentsListing($directoryPath, false) as $item) { + if (!$item->isFile()) { continue; } - $path = $item['path'] ?? null; - if (!is_string($path) || $path === '') { + $path = $item->path(); + if ($path === '') { continue; } diff --git a/tests/Feature/AuditTrailTest.php b/tests/Feature/AuditTrailTest.php index e5688c8..244d26b 100644 --- a/tests/Feature/AuditTrailTest.php +++ b/tests/Feature/AuditTrailTest.php @@ -1,5 +1,7 @@ log('valid'); + $stream = fopen('php://temp', 'rb'); + + try { + expect(fn() => $audit->log('invalid', ['stream' => $stream]))->toThrow(JsonException::class) + ->and(file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES))->toHaveCount(1); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + if (is_file($logFile)) { + unlink($logFile); + } + } +}); diff --git a/tests/Feature/ChecksumIndexerTest.php b/tests/Feature/ChecksumIndexerTest.php index 02e74a4..c3d746c 100644 --- a/tests/Feature/ChecksumIndexerTest.php +++ b/tests/Feature/ChecksumIndexerTest.php @@ -1,5 +1,7 @@ and(is_file($b))->toBeTrue(); }); +test('it preserves unrelated files beside a deduplication target', function () { + $a = $this->checksumDir . DIRECTORY_SEPARATOR . 'a.txt'; + $b = $this->checksumDir . DIRECTORY_SEPARATOR . 'b.txt'; + $sentinel = $b . '.tmp_delete'; + file_put_contents($a, 'same-content'); + file_put_contents($b, 'same-content'); + file_put_contents($sentinel, 'must-survive'); + + ChecksumIndexer::deduplicateWithHardLinks($this->checksumDir); + + expect(file_get_contents($sentinel))->toBe('must-survive'); +}); + test('it builds duplicate index for mounted paths and skips hard links there', function () { FlysystemHelper::write('chk://a.txt', 'same-content'); FlysystemHelper::write('chk://b.txt', 'same-content'); diff --git a/tests/Feature/DirectoryOperationsFlysystemTest.php b/tests/Feature/DirectoryOperationsFlysystemTest.php index 2daa1d8..8b22bbd 100644 --- a/tests/Feature/DirectoryOperationsFlysystemTest.php +++ b/tests/Feature/DirectoryOperationsFlysystemTest.php @@ -1,5 +1,7 @@ and($stats['failed'])->toBe(1); }); +test('it limits processing attempts even when jobs fail', function () { + $queue = new FileJobQueue($this->queueFile); + $queue->enqueue('first'); + $queue->enqueue('second'); + + $result = $queue->process(static function (): void { + throw new RuntimeException('boom'); + }, 1); + $stats = $queue->stats(); + + expect($result)->toBe(['processed' => 0, 'failed' => 1]) + ->and($stats)->toMatchArray(['pending' => 1, 'processing' => 0, 'failed' => 1]); +}); + +test('it creates opaque job identifiers and rejects corrupt queue data', function () { + $queue = new FileJobQueue($this->queueFile); + $jobId = $queue->enqueue('opaque'); + + expect($jobId)->toMatch('/^job_[a-f0-9]{32}$/'); + + file_put_contents($this->queueFile, '{invalid'); + expect(fn() => $queue->stats())->toThrow(RuntimeException::class, 'invalid JSON'); +}); diff --git a/tests/Feature/FileOperationsAdvancedTest.php b/tests/Feature/FileOperationsAdvancedTest.php index fe680be..9284258 100644 --- a/tests/Feature/FileOperationsAdvancedTest.php +++ b/tests/Feature/FileOperationsAdvancedTest.php @@ -1,5 +1,7 @@ filePath))->toBe('original'); }); - diff --git a/tests/Feature/FileOperationsTest.php b/tests/Feature/FileOperationsTest.php index 24c36f0..3f910ce 100644 --- a/tests/Feature/FileOperationsTest.php +++ b/tests/Feature/FileOperationsTest.php @@ -1,5 +1,7 @@ tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('func_dir_', true); mkdir($this->tempDir); @@ -17,7 +19,8 @@ }); test('it formats file size to human readable text', function () { - expect(getHumanReadableFileSize(1024))->toBe('1.00 KB'); + expect(getHumanReadableFileSize(1024))->toBe('1.00 KB') + ->and(getHumanReadableFileSize(1024 ** 5))->toBe('1,024.00 TB'); }); test('it reports directory empty state correctly', function () { diff --git a/tests/Feature/MetadataHelperTest.php b/tests/Feature/MetadataHelperTest.php index 7fdefef..a97c50e 100644 --- a/tests/Feature/MetadataHelperTest.php +++ b/tests/Feature/MetadataHelperTest.php @@ -1,5 +1,7 @@ toBeTrue(); rmdir($tempDir); }); + +test('it confines temporary directories to the system temp directory', function () { + $tempDir = PathHelper::createTempDirectory('../unsafe/prefix_'); + + expect($tempDir)->toBeString() + ->and(dirname((string) $tempDir))->toBe(PathHelper::normalize(sys_get_temp_dir())) + ->and(basename((string) $tempDir))->not->toContain('/') + ->not->toContain('\\'); + + rmdir((string) $tempDir); +}); diff --git a/tests/Feature/PermissionsHelperTest.php b/tests/Feature/PermissionsHelperTest.php index 9e4a809..2dd3a24 100644 --- a/tests/Feature/PermissionsHelperTest.php +++ b/tests/Feature/PermissionsHelperTest.php @@ -1,5 +1,7 @@ toBe('rwxr-xr-x'); })->skip(PHP_OS_FAMILY === 'Windows'); + +test('it formats special permission bits without inventing execute access', function () { + expect(PermissionsHelper::formatPermissions(0644 | 04000))->toBe('rwSr--r--') + ->and(PermissionsHelper::formatPermissions(0644 | 02000))->toBe('rw-r-Sr--') + ->and(PermissionsHelper::formatPermissions(0644 | 01000))->toBe('rw-r--r-T'); +})->skip(PHP_OS_FAMILY === 'Windows'); + +test('it observes permission changes made outside the helper', function () { + chmod($this->tempFilePath, 0644); + expect(PermissionsHelper::getPermissions($this->tempFilePath))->toBe('0644'); + + chmod($this->tempFilePath, 0600); + expect(PermissionsHelper::getPermissions($this->tempFilePath))->toBe('0600'); +})->skip(PHP_OS_FAMILY === 'Windows'); diff --git a/tests/Feature/PolicyEngineTest.php b/tests/Feature/PolicyEngineTest.php index 6d9f480..a2f80ad 100644 --- a/tests/Feature/PolicyEngineTest.php +++ b/tests/Feature/PolicyEngineTest.php @@ -1,5 +1,7 @@ toHaveCount(2) ->and($report['deleted'])->toHaveCount(1); }); + +test('it rejects invalid retention options', function () { + expect(fn() => RetentionManager::apply($this->retentionDir, keepLast: -1)) + ->toThrow(InvalidArgumentException::class) + ->and(fn() => RetentionManager::apply($this->retentionDir, maxAgeDays: -1)) + ->toThrow(InvalidArgumentException::class) + ->and(fn() => RetentionManager::apply($this->retentionDir, sortBy: 'size')) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/SafeFileReaderTest.php b/tests/Feature/SafeFileReaderTest.php index 3a59478..f3c5a9e 100644 --- a/tests/Feature/SafeFileReaderTest.php +++ b/tests/Feature/SafeFileReaderTest.php @@ -1,5 +1,7 @@