Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/queue.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
8 changes: 5 additions & 3 deletions src/DirectoryManager/DirectoryOperations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}

Expand Down
8 changes: 4 additions & 4 deletions src/FileManager/Concerns/FileCompressionArchiveConcern.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 21 additions & 15 deletions src/FileManager/Concerns/FsConcern.php
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}

Expand Down Expand Up @@ -112,18 +112,24 @@ private function doLocalizeRemoteFileSource(string $normalizedSource, string $or
return PathHelper::normalize($tempFile);
}

/** @param list<string> $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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
}
}

Expand Down
102 changes: 76 additions & 26 deletions src/Indexing/ChecksumIndexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -127,49 +175,39 @@ private static function isLocalFile(string $path): bool
return !PathHelper::hasScheme($path) && is_file($path);
}

/**
* @return list<string>
*/
private static function iterFiles(string $directory): array
/** @return \Generator<int, string> */
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<string>
*/
private static function iterFilesLocal(string $directory): array
/** @return \Generator<int, string> */
private static function iterFilesLocal(string $directory): \Generator
{
$paths = [];
foreach (LocalFileIterator::files($directory) as $item) {
$paths[] = $item->getPathname();
yield $item->getPathname();
}

return $paths;
}

/**
* @return list<string>
*/
private static function iterFilesViaFlysystem(string $directory): array
/** @return \Generator<int, string> */
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
Expand All @@ -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)) {
Expand Down
22 changes: 18 additions & 4 deletions src/Observability/AuditTrail.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use DateTimeInterface;
use Infocyph\Pathwise\Utils\FlysystemHelper;
use Infocyph\Pathwise\Utils\PathHelper;
use RuntimeException;

final readonly class AuditTrail
{
Expand Down Expand Up @@ -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());
}
}
Loading