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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ d2utmp*
plan.md
pathwise.md
feature.md
.agent
.codex
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,17 @@ $report = (new DirectoryOperations('/srv/source'))->syncTo(

## Secure archives

Every extraction path validates every ZIP member before writing. Absolute paths, Windows drive paths, null bytes, traversal segments, symbolic-link entries, extraction-root escapes, and existing destination-symlink breakouts are rejected with `UnsafeArchiveEntryException`.
Every extraction path validates every ZIP member before writing. Absolute paths, Windows drive paths, null bytes, traversal segments, symbolic-link entries, extraction-root escapes, and existing destination-symlink breakouts are rejected with `UnsafeArchiveEntryException`. Default entry-count, per-entry size, total uncompressed-size, and compression-ratio limits mitigate ZIP bombs and can be configured explicitly.

`FileJobQueue` is direct-local-only and intended for bounded, lightweight single-host workloads—not as a remote or distributed broker.

## Auditing

`AuditTrail` accepts a local JSONL path or an `AuditSink`. `LocalJsonlAuditSink` uses locked append. `PartitionedAuditSink` writes one object per event and is suitable for mounted object stores. `CallbackAuditSink` integrates application loggers. Remote audit append is never silently emulated by reading and rewriting a log object.

## Native execution

`ExecutionStrategy::PHP` always uses PHP, `AUTO` may use an available native executable and fall back, and `NATIVE` either completes natively or throws `NativeExecutionException`. Native execution accepts local paths only; command arguments are escaped and execution results retain command, output, and exit code.
`ExecutionStrategy::PHP` always uses PHP, `AUTO` may use an available native executable and fall back, and `NATIVE` either completes natively or throws `NativeExecutionException`. Native execution accepts local paths only; commands are executed as argument arrays without a shell, and execution results retain command, output, and exit code.

## Security

Expand Down
12 changes: 12 additions & 0 deletions benchmarks/FlysystemHelperBench.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ public function benchChecksumSha256(): void
FlysystemHelper::checksum($this->filePath, 'sha256');
}

public function benchCopyThroughCachedLocalResolver(): void
{
$destination = PathHelper::join($this->baseDir, 'copy.txt');
FlysystemHelper::copy($this->filePath, $destination);
FlysystemHelper::delete($destination);
}

public function benchFileExistsThroughCachedLocalResolver(): void
{
FlysystemHelper::fileExists($this->filePath);
}

public function benchGetMetadataSizeAndMtime(): void
{
FlysystemHelper::size($this->filePath);
Expand Down
191 changes: 191 additions & 0 deletions benchmarks/ReleaseWorkloadsBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<?php

declare(strict_types=1);

namespace Infocyph\Pathwise\Benchmarks;

use Infocyph\Pathwise\Core\SyncComparison;
use Infocyph\Pathwise\DirectoryManager\DirectoryOperations;
use Infocyph\Pathwise\FileManager\FileOperations;
use Infocyph\Pathwise\FileManager\SafeFileReader;
use Infocyph\Pathwise\Queue\FileJobQueue;
use Infocyph\Pathwise\StreamHandler\DownloadProcessor;
use Infocyph\Pathwise\StreamHandler\UploadProcessor;
use Infocyph\Pathwise\Utils\FlysystemHelper;
use Infocyph\Pathwise\Utils\PathHelper;
use PhpBench\Attributes as Bench;

#[Bench\Iterations(1)]
#[Bench\Revs(1)]
#[Bench\BeforeMethods(['setUp'])]
#[Bench\AfterMethods(['tearDown'])]
final class ReleaseWorkloadsBench
{
private string $baseDirectory;

private string $largeFile;

private string $sourceDirectory;

public function setUp(): void
{
$this->baseDirectory = PathHelper::join(sys_get_temp_dir(), 'pathwise_release_bench_' . uniqid('', true));
$this->sourceDirectory = PathHelper::join($this->baseDirectory, 'source');
$this->largeFile = PathHelper::join($this->baseDirectory, 'large.bin');
}

public function tearDown(): void
{
if (FlysystemHelper::directoryExists($this->baseDirectory)) {
FlysystemHelper::deleteDirectory($this->baseDirectory);
}
}

public function benchChunkAssembly100(): void
{
$this->benchmarkChunkAssembly(100);
}

public function benchChunkAssembly1000ReverseArrival(): void
{
$this->benchmarkChunkAssembly(1_000, true);
}

public function benchQueue100(): void
{
$this->benchmarkQueue(100);
}

public function benchQueue1000(): void
{
$this->benchmarkQueue(1_000);
}

public function benchQueue10000(): void
{
$this->benchmarkQueue(10_000);
}

public function benchReader128KiB(): void
{
$this->consumeReader(131_072);
}

public function benchReader64KiB(): void
{
$this->consumeReader(65_536);
}

public function benchReader8KiB(): void
{
$this->consumeReader(8_192);
}

public function benchSyncChecksum1000Files(): void
{
$this->benchmarkSync(SyncComparison::CHECKSUM);
}

public function benchSyncSize1000Files(): void
{
$this->benchmarkSync(SyncComparison::SIZE);
}

public function benchSyncSizeAndModifiedTime1000Files(): void
{
$this->benchmarkSync(SyncComparison::SIZE_AND_MODIFIED_TIME);
}

public function benchTransactionHundredUpdates(): void
{
$this->benchmarkTransaction(100);
}

public function benchTransactionOneUpdate(): void
{
$this->benchmarkTransaction(1);
}

public function benchTransactionTenUpdates(): void
{
$this->benchmarkTransaction(10);
}

private function benchmarkChunkAssembly(int $chunks, bool $reverse = false): void
{
$uploadDirectory = PathHelper::join($this->baseDirectory, 'uploads-' . $chunks);
$temporaryDirectory = PathHelper::join($this->baseDirectory, 'chunks-' . $chunks);
$uploader = new UploadProcessor();
$uploader->setDirectorySettings($uploadDirectory, false, $temporaryDirectory);
$indexes = range(0, $chunks - 1);
if ($reverse) {
$indexes = array_reverse($indexes);
}
foreach ($indexes as $index) {
$part = PathHelper::join($this->baseDirectory, "part-{$chunks}-{$index}.tmp");
FlysystemHelper::write($part, 'x');
$uploader->processChunkUpload([
'error' => UPLOAD_ERR_OK,
'size' => 1,
'tmp_name' => $part,
'name' => basename($part),
], "bench_{$chunks}", $index, $chunks, 'assembled.txt');
}
$uploader->finalizeChunkUpload("bench_{$chunks}");
}

private function benchmarkQueue(int $jobs): void
{
$queue = new FileJobQueue(PathHelper::join($this->baseDirectory, "queue-{$jobs}.json"), maxJobs: $jobs);
for ($index = 0; $index < $jobs; $index++) {
$queue->enqueue('benchmark', ['index' => $index]);
}
$queue->process(static function (): void {});
}

private function benchmarkSync(SyncComparison $comparison): void
{
FlysystemHelper::createDirectory($this->sourceDirectory);
for ($index = 0; $index < 1_000; $index++) {
FlysystemHelper::write(
PathHelper::join($this->sourceDirectory, sprintf('entry-%04d.txt', $index)),
str_repeat((string) ($index % 10), 128),
);
}
$target = PathHelper::join($this->baseDirectory, 'sync-' . $comparison->name);
(new DirectoryOperations($this->sourceDirectory))->syncTo($target, true, null, $comparison);
}

private function benchmarkTransaction(int $updates): void
{
$this->createLargeFile();
$path = PathHelper::join($this->baseDirectory, "transaction-{$updates}.bin");
FlysystemHelper::copy($this->largeFile, $path);
(new FileOperations($path))->transaction(static function (FileOperations $operations) use ($updates): void {
for ($index = 0; $index < $updates; $index++) {
$operations->update(str_repeat((string) ($index % 10), 8 * 1024 * 1024));
}
});
}

private function consumeReader(int $chunkSize): void
{
$this->createLargeFile();
foreach ((new SafeFileReader($this->largeFile))->chunks($chunkSize) as $chunk) {
strlen($chunk);
}

$download = new DownloadProcessor();
$download->setChunkSize($chunkSize);
$output = fopen('php://temp', 'w+b');
if (is_resource($output)) {
$download->streamDownload($this->largeFile, $output);
fclose($output);
}
}

private function createLargeFile(): void
{
FlysystemHelper::write($this->largeFile, str_repeat('0123456789abcdef', 512 * 1024));
}
}
2 changes: 1 addition & 1 deletion benchmarks/StreamHandlerBench.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public function benchProcessUpload(): void
file_put_contents($tmpUpload, str_repeat('upload-payload', 128));

try {
$destination = $this->uploadProcessor->processUpload([
$destination = $this->uploadProcessor->ingestFile([
'error' => UPLOAD_ERR_OK,
'size' => filesize($tmpUpload) ?: 0,
'tmp_name' => $tmpUpload,
Expand Down
2 changes: 1 addition & 1 deletion docs/_static/theme.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
.highlight-php .k {
color: #0077aa; /* Example: make PHP keywords a different color */
color: #0077aa;
}
8 changes: 1 addition & 7 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"myst_parser",
"sphinx.ext.todo",
"sphinx.ext.autosectionlabel",
"sphinx.ext.intersphinx",
"sphinx_copybutton",
"sphinx_design",
"sphinxcontrib.phpdomain",
Expand All @@ -33,11 +32,7 @@

myst_heading_anchors = 3
autosectionlabel_prefix_document = True
todo_include_todos = True

intersphinx_mapping = {
"php": ("https://www.php.net/manual/en/", None),
}
todo_include_todos = False

html_theme = "sphinx_book_theme"
html_theme_options = {
Expand All @@ -58,4 +53,3 @@
html_show_sourcelink = True
html_show_sphinx = False
html_last_updated_fmt = "%Y-%m-%d"

7 changes: 4 additions & 3 deletions docs/download-processing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,23 @@ Prepare secure metadata:
rangeHeader: null,
);

// Use $manifest['status'] and $manifest['headers'] in your framework response.
// Use $manifest->status, $manifest->headers, and $manifest->range.

Stream output with range support:

.. code-block:: php

$output = fopen('php://output', 'wb');

$manifest = $downloads->streamDownload(
$result = $downloads->streamDownload(
path: '/srv/app/downloads/video.mp4',
outputStream: $output,
downloadName: 'video.mp4',
rangeHeader: $_SERVER['HTTP_RANGE'] ?? null,
);

// $manifest includes status, headers, rangeStart/rangeEnd and bytesSent.
// $result->preparation contains status, headers, and range metadata.
// $result->bytesSent is the number of bytes written to the output stream.

Mounted storage example:

Expand Down
4 changes: 3 additions & 1 deletion docs/file-manager.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ Example:

Brief capabilities:

* Memory-safe reads: line, char, binary chunk, CSV, JSON, XML.
* Streaming line, character, binary chunk, CSV, JSON Lines, and XML modes.
* Whole-document ``jsonArray()`` decoding for complete JSON arrays (memory use
is proportional to the document size).
* Lock-aware reads for safer concurrent usage.
* Explicit generator APIs; the reader itself implements ``Countable``, not ``Iterator``.

Expand Down
9 changes: 6 additions & 3 deletions docs/queue.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ Brief capabilities:

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.
all attempted jobs, including failures. Queue files must be direct-local paths;
mounted and default-Flysystem paths are rejected.

``FileJobQueue`` is intended for lightweight single-host workloads. It uses
local file locks and bounded payload/job/file sizes; it is not a remote or
distributed broker.

Good fit:

Expand Down
5 changes: 4 additions & 1 deletion docs/recipes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ Goal:
$audit->log('upload.processed', ['path' => $finalPath]);

$retention = RetentionManager::apply('/tmp/uploads', keepLast: 50, maxAgeDays: 30);
$audit->log('retention.applied', $retention);
$audit->log('retention.applied', [
'deleted' => $retention->deleted,
'kept' => $retention->kept,
]);

Recipe 2: Mirror + Zip + Checksum
---------------------------------
Expand Down
7 changes: 7 additions & 0 deletions docs/retention.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Brief capabilities:
* Keep only latest N files.
* Delete files older than configured age threshold.
* Combine count-based and age-based pruning.
* Return a readonly ``RetentionResult`` with ``deleted`` and ``kept`` lists.
* Use ``mtime`` for adapter-backed storage; ``ctime`` is a direct-local-only
capability and is rejected for mounted paths.

Use cases:

Expand All @@ -29,3 +32,7 @@ Example
maxAgeDays: 30,
sortBy: 'mtime',
);

foreach ($report->deleted as $deletedPath) {
// Record or report the deleted path.
}
5 changes: 5 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ Brief capabilities:
* Support conditional callbacks for context-aware checks.
* Enforce policy with explicit violations via ``PolicyViolationException``.

Rules are evaluated in registration order and the **last matching rule wins**.
This makes it possible to establish a broad default and then add narrower
exceptions. Direct-local Windows paths are matched case-insensitively, while
mounted and object-storage paths retain their adapter's case semantics.

Typical use:

* Restrict write/delete to approved roots.
Expand Down
6 changes: 5 additions & 1 deletion docs/storage-contracts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ ZIP Extraction
the entire archive before extraction and rejects absolute/drive paths, null
bytes, traversal, root escape, ZIP symbolic links, and existing destination
symlink chains. Remote archives and destinations are localized/streamed only
after applying the same validation.
after applying the same validation. Entry-count, per-entry uncompressed-size,
total uncompressed-size, and compression-ratio limits are enforced before any
destination mutation. Remote compression stages each entry to bounded temporary
disk and registers the staged file with ``ZipArchive``; it does not load an
entire remote entry into one PHP string.

Synchronization
---------------
Expand Down
Loading
Loading