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 src/docs/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,8 @@ $disk = Storage::build($configuration, 'tenant-uploads');

Hypervel uses this name as part of the pool identity for drivers that pool the complete filesystem instance. An on-demand disk does not register a serving route of its own. When a named on-demand disk enables `serve`, Hypervel generates signed URLs through the configured served disk with that name. Use matching storage configuration because the route resolves the configured disk, not the on-demand instance. An anonymous scoped disk may instead generate signed URLs through a named parent disk that has serving enabled. S3 and Google Cloud Storage client pools continue to use the client configuration rather than the logical disk name.

The disk name `ondemand` is reserved and cannot be used in your `filesystems.disks` configuration. In tests, `Storage::fake('ondemand')` or `Storage::persistentFake('ondemand')` replaces disks returned by `Storage::build()` when no logical name is supplied. Builds with an explicit logical name continue to use their own configuration.

<a name="retrieving-files"></a>
## Retrieving Files

Expand Down
2 changes: 2 additions & 0 deletions src/docs/porting-from-laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,8 @@ Hypervel's `Filesystem::hash()` method uses `xxh128` by default. Pass `md5` expl

Unlike Laravel, Hypervel honors `read-only` on scoped disk records. Remove that option from any scoped disk that must accept writes.

Rename any configured disk called `ondemand`; Hypervel reserves that name for [on-demand disk fakes](/docs/{{version}}/filesystem#on-demand-disks).

<a name="tinker"></a>
### Tinker

Expand Down
2 changes: 2 additions & 0 deletions src/filesystem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Hypervel omits Laravel's legacy `Storage::cloud()` / `filesystem.cloud` default-

Hypervel's `Filesystem::hash()` method uses `xxh128` by default instead of `md5`. Pass `md5` explicitly when Laravel-compatible digests are required.

The configured disk name `ondemand` is reserved. Disks explicitly set under that name, including fakes, intercept anonymous `build()` calls; configured disks with that name are rejected on resolution.

Hypervel pools S3 and Google Cloud Storage SDK clients rather than complete disk adapters. Disks with equivalent client construction config share the expensive client pool while retaining their own bucket, root, visibility, and callback behavior. Pooled disks expose raw internals only through borrow-scoped `withClient()`, `withDriver()`, and `withAdapter()` callbacks.

Filesystem construction differs from Laravel in how it carries logical disk identity. `callCustomCreator()` accepts the logical disk name as an optional second parameter, so existing one-argument calls remain valid while overrides must adopt the parameter. The public `build()` method uses a logical-name-aware construction path rather than the protected `resolve()` method because anonymous builds must pass a null name to creators; `resolve()` remains the configured-disk seam. `createScopedDriver()` resolves its prepared descriptor directly rather than through `build()`, so override `createScopedDriver()` for scoped construction customization. Customize on-demand construction through `Storage::extend()` or the public driver creator methods. Creator callbacks may accept the nullable name as a third argument after the application and configuration, while existing two-argument callbacks remain valid. Hypervel carries the name through scoped reconstruction and whole-driver pool fingerprints. A matching explicit fingerprint declares that every construction detail is equivalent, including serving-route ownership.
Expand Down
16 changes: 13 additions & 3 deletions src/filesystem/src/FilesystemManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ public function disk(UnitEnum|string|null $name = null): Filesystem
*/
public function build(array|string $config, ?string $name = null): Filesystem
{
if ($name === null && isset($this->disks[self::ON_DEMAND_DISK_NAME])) {
return $this->disks[self::ON_DEMAND_DISK_NAME];
}

$config = is_array($config) ? $config : [
'driver' => 'local',
'root' => $config,
Expand Down Expand Up @@ -170,8 +174,8 @@ protected function resolve(string $name, ?array $config = null): Filesystem
/**
* Resolve the given disk while preserving its logical construction name.
*
* The configured disk name "ondemand" is valid, so build() enters this
* method directly to carry anonymous construction as a separate value.
* Anonymous builds carry a null logical name through custom creators
* and pool identity, independently of their internal construction name.
*/
private function resolveWithLogicalName(string $name, ?array $config, ?string $logicalName): Filesystem
{
Expand Down Expand Up @@ -834,7 +838,13 @@ public function set(string $name, mixed $disk): static
*/
protected function getConfig(string $name): array
{
return $this->app->make('config')->get("filesystems.disks.{$name}") ?: [];
$config = $this->app->make('config')->get("filesystems.disks.{$name}") ?: [];

if ($name === self::ON_DEMAND_DISK_NAME && $config !== []) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: An explicitly configured empty ondemand disk bypasses this reservation check because $config !== [] is false. Detect whether the configuration key exists separately from its value so every configured ondemand disk receives the rename error.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/filesystem/src/FilesystemManager.php, line 843:

<comment>An explicitly configured empty `ondemand` disk bypasses this reservation check because `$config !== []` is false. Detect whether the configuration key exists separately from its value so every configured `ondemand` disk receives the rename error.</comment>

<file context>
@@ -834,7 +838,13 @@ public function set(string $name, mixed $disk): static
-        return $this->app->make('config')->get("filesystems.disks.{$name}") ?: [];
+        $config = $this->app->make('config')->get("filesystems.disks.{$name}") ?: [];
+
+        if ($name === self::ON_DEMAND_DISK_NAME && $config !== []) {
+            throw new InvalidArgumentException('The disk name [ondemand] is reserved for on-demand disk fakes. Rename the configured disk.');
+        }
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An empty configuration cannot construct a disk: resolution already rejects it with the missing-driver error, and it cannot populate the cache entry that intercepts anonymous builds. The reservation check prevents actual configured disks from occupying that slot. Distinguishing an empty entry from an absent one only changes the error wording and does not fix a behavior or safety gap; absent configuration also remains valid for fake cleanup.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This makes serving URLs from build($config, 'ondemand') unusable: the generated route must resolve a configured ondemand disk, but this guard rejects it. Preserve a resolvable serving disk for explicitly named builds, or prevent those builds from generating serving URLs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/filesystem/src/FilesystemManager.php, line 843:

<comment>This makes serving URLs from `build($config, 'ondemand')` unusable: the generated route must resolve a configured `ondemand` disk, but this guard rejects it. Preserve a resolvable serving disk for explicitly named builds, or prevent those builds from generating serving URLs.</comment>

<file context>
@@ -834,7 +838,13 @@ public function set(string $name, mixed $disk): static
-        return $this->app->make('config')->get("filesystems.disks.{$name}") ?: [];
+        $config = $this->app->make('config')->get("filesystems.disks.{$name}") ?: [];
+
+        if ($name === self::ON_DEMAND_DISK_NAME && $config !== []) {
+            throw new InvalidArgumentException('The disk name [ondemand] is reserved for on-demand disk fakes. Rename the configured disk.');
+        }
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configured ondemand disks are intentionally unsupported; the porting guide requires a different name. Local serving already requires a valid matching configured disk, and an explicit build does not register routes. Use a non-reserved name for both when serving files. Preserving a configured ondemand serving disk would undo the intended reservation; an additional special-case guard for this unsupported configuration is unnecessary.

throw new InvalidArgumentException('The disk name [ondemand] is reserved for on-demand disk fakes. Rename the configured disk.');
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
}
Comment on lines +843 to +845

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Serving route fails late

If a configured ondemand disk has serve: true, the service provider registers its file-serving route, but a request to that route throws when it tries to resolve the disk. Reject the reserved name during route registration too, so this invalid configuration is caught at boot rather than leaving a route that fails when used.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation intentionally happens when the disk is resolved, as with other invalid disk configurations. File-serving route registration does not construct the driver. Using this reserved configuration then raises the explicit rename error. A second check during route registration would duplicate that validation; boot-time rejection is not part of this contract.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. serveFiles() only registers the route and defers Storage::disk($disk) until request handling, so the existing getConfig() check still emits the explicit rename error at resolution time. A second check during route registration would change the established lazy-validation behavior without adding correctness here, so I’m withdrawing this finding.


return $config;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/routing/src/UrlGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ public function hasCorrectSignature(Request $request, bool $absolute = true, Clo

$url = $absolute
? rtrim($request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo(), '/')
: '/' . $request->path();
: '/' . trim($request->getPathInfo(), '/');

// REMOVED: Vapor's VAPOR_RAW_QUERY_STRING override; Swoole supplies the raw QUERY_STRING.
// Keep this explode() to avoid an extra Stringable allocation per call.
Expand Down
32 changes: 21 additions & 11 deletions tests/Filesystem/FilesystemManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ public function testCanBuildOnDemandDisk(): void
]));
}

public function testConfiguredOndemandDiskNameIsReserved(): void
{
$filesystem = new FilesystemManager($this->getContainer([
'disks' => [
'ondemand' => ['driver' => 'local', 'root' => $this->tempDir],
],
]));

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The disk name [ondemand] is reserved for on-demand disk fakes. Rename the configured disk.');

$filesystem->disk('ondemand');
}

public function testCanBuildReadOnlyDisks(): void
{
$filesystem = new FilesystemManager($this->getContainer());
Expand Down Expand Up @@ -1198,9 +1212,7 @@ public function testCustomCreatorsReceiveTheExactLogicalName(): void
'driver' => 'custom',
'root' => $this->tempDir . '/custom-names',
];
$container = $this->getContainer([
'disks' => ['ondemand' => $config],
]);
$container = $this->getContainer();
$received = [];
$manager = new FilesystemManager($container);
$manager->extend('custom', function (Container $app, array $config, ?string $name) use (&$received): FilesystemAdapter {
Expand All @@ -1212,7 +1224,7 @@ public function testCustomCreatorsReceiveTheExactLogicalName(): void

$manager->build($config);
$manager->build($config, 'uploads');
$manager->disk('ondemand');
$manager->build($config, 'ondemand');

$this->assertSame([null, 'uploads', 'ondemand'], $received);
}
Expand Down Expand Up @@ -1249,7 +1261,6 @@ public function testCustomPoolableDriversIncludeLogicalNamesAndExcludePoolMetada
'disks' => [
'first' => $config,
'second' => $config,
'ondemand' => $config,
],
]);
Container::setInstance($container);
Expand All @@ -1264,15 +1275,15 @@ public function testCustomPoolableDriversIncludeLogicalNamesAndExcludePoolMetada
$first = $manager->disk('first');
$second = $manager->disk('second');
$anonymous = $manager->build($config);
$configuredOndemand = $manager->disk('ondemand');
$namedOndemand = $manager->build($config, 'ondemand');

$this->assertInstanceOf(FilesystemPoolProxy::class, $first);
$this->assertNotSame($first->getPoolName(), $second->getPoolName());
$this->assertNotSame($anonymous->getPoolName(), $configuredOndemand->getPoolName());
$this->assertNotSame($anonymous->getPoolName(), $namedOndemand->getPoolName());
$this->assertFalse($first->exists('missing.txt'));
$this->assertFalse($second->exists('missing.txt'));
$this->assertFalse($anonymous->exists('missing.txt'));
$this->assertFalse($configuredOndemand->exists('missing.txt'));
$this->assertFalse($namedOndemand->exists('missing.txt'));
$this->assertSame(['first', 'second', null, 'ondemand'], array_column($received, 1));

foreach ($received as [$receivedConfig]) {
Expand All @@ -1292,20 +1303,19 @@ public function testPoolableBuiltInDriversIncludeTheLogicalNameInConstructionIde
'disks' => [
'first' => $config,
'second' => $config,
'ondemand' => $config,
],
]);
$manager = (new FilesystemManager($container))->addPoolableDriver('local');

$first = $manager->disk('first');
$second = $manager->disk('second');
$configuredOndemand = $manager->disk('ondemand');
$namedOndemand = $manager->build($config, 'ondemand');
$anonymous = $manager->build($config);

$this->assertInstanceOf(FilesystemPoolProxy::class, $first);
$this->assertInstanceOf(FilesystemPoolProxy::class, $second);
$this->assertNotSame($first->getPoolName(), $second->getPoolName());
$this->assertNotSame($configuredOndemand->getPoolName(), $anonymous->getPoolName());
$this->assertNotSame($namedOndemand->getPoolName(), $anonymous->getPoolName());
}

public function testWholeDriverPoolsIncludeRouteOwnershipNotImpliedByEffectiveConfiguration(): void
Expand Down
2 changes: 1 addition & 1 deletion tests/Http/HttpRequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2257,7 +2257,7 @@ public function testItDoesNotGenerateJsonErrorsForEmptyContent(): void

Request::create('', 'GET')->json();

$this->assertTrue(json_last_error() === JSON_ERROR_NONE);
$this->assertSame(JSON_ERROR_NONE, json_last_error());
}

public function testItClampsValues(): void
Expand Down
2 changes: 1 addition & 1 deletion tests/Http/JsonResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public function testJsonResourceToJsonSucceedsWithPriorErrors(): void

// Simulate a JSON error
json_decode('{');
$this->assertTrue(json_last_error() !== JSON_ERROR_NONE);
$this->assertNotSame(JSON_ERROR_NONE, json_last_error());

$this->assertSame('{"foo":"bar"}', $resource->toJson(JSON_THROW_ON_ERROR));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@ public function testModelWithUlidPrimaryKeyCanBeCreated()
$this->assertTrue(Str::isUlid($user->bar));
}

public function testModelWithoutUuidPrimaryKeyCanBeCreated()
public function testModelWithoutUuidPrimaryKeyCanBeCreated(): void
{
$user = ModelWithoutUuidPrimaryKey::create();

$this->assertTrue(is_int($user->id));
$this->assertIsInt($user->id);
$this->assertTrue(Str::isUuid($user->foo));
$this->assertTrue(Str::isUuid($user->bar));
}
Expand Down Expand Up @@ -105,13 +105,13 @@ public function testModelWithUlidPrimaryKeyCanBeCreatedQuietly()
$this->assertTrue(Str::isUlid($user->bar));
}

public function testModelWithoutUuidPrimaryKeyCanBeCreatedQuietly()
public function testModelWithoutUuidPrimaryKeyCanBeCreatedQuietly(): void
{
$user = new ModelWithoutUuidPrimaryKey;

$user->saveQuietly();

$this->assertTrue(is_int($user->id));
$this->assertIsInt($user->id);
$this->assertTrue(Str::isUuid($user->foo));
$this->assertTrue(Str::isUuid($user->bar));
}
Expand Down
4 changes: 2 additions & 2 deletions tests/Integration/Database/SchemaBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -392,14 +392,14 @@ public function testGetColumns()
));
}

public function testGetColumnsOnView()
public function testGetColumnsOnView(): void
{
DB::statement('create view foo (bar) as select 1');

$columns = Schema::getColumns('foo');

$this->assertCount(1, $columns);
$this->assertTrue($columns[0]['name'] === 'bar');
$this->assertSame('bar', $columns[0]['name']);
}

public function testGetIndexes()
Expand Down
37 changes: 37 additions & 0 deletions tests/Integration/Support/StorageFacadeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,43 @@ public function testStorageFakeMethodsWithEnums(): void
$this->assertNull(Storage::fake(StorageFakeStringDisk::Public)->get('nonExistentFile'));
}

public function testCanFakeOnDemandDisk(): void
{
$fake = Storage::fake('ondemand');

Storage::build([
'driver' => 'ftp',
'host' => 'example.com',
'username' => 'foo',
'password' => 'bar',
])->put('file.txt', 'contents');

$fake->assertExists('file.txt');
}

public function testOnDemandFakesPreserveNamedBuildsAndCanBeCleared(): void
{
$root = storage_path('on-demand');
$config = ['driver' => 'local', 'root' => $root];
$fake = Storage::persistentFake('ondemand');

$this->assertSame($fake, Storage::build($config));
$this->assertNotSame($fake, Storage::build($config, 'uploads'));

Storage::forgetDisk('ondemand');
Storage::build($config)->put('after-forget.txt', 'contents');
$this->assertFileExists($root . '/after-forget.txt');
$fake->assertMissing('after-forget.txt');

$fake = Storage::fake('ondemand');
$this->assertSame($fake, Storage::build($root));

Storage::purge('ondemand');
Storage::build($root)->put('after-purge.txt', 'contents');
$this->assertFileExists($root . '/after-purge.txt');
$fake->assertMissing('after-purge.txt');
}

public function testFakePreservesOriginalDiskThrowConfig(): void
{
config(['filesystems.disks.local.throw' => true]);
Expand Down
4 changes: 2 additions & 2 deletions tests/Process/ProcessTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,8 @@ public function testProcessPoolCanReceiveOutputForEachProcessViaStartMethod(): v

$poolResults = $pool->wait();

$this->assertTrue($output[0]['out'] !== []);
$this->assertTrue($output[1]['out'] !== []);
$this->assertNotSame([], $output[0]['out']);
$this->assertNotSame([], $output[1]['out']);
$this->assertInstanceOf(ProcessResult::class, $poolResults[0]);
$this->assertInstanceOf(ProcessResult::class, $poolResults[1]);
$this->assertStringContainsString('ProcessTest.php', $poolResults[0]->output());
Expand Down
16 changes: 13 additions & 3 deletions tests/Routing/RoutingUrlGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1150,17 +1150,17 @@ public function testSignedUrlImplicitModelBinding()
$this->assertTrue($url->hasValidSignature($request));
}

public function testSignedRelativeUrl()
public function testSignedRelativeUrl(): void
{
$url = new UrlGenerator(
$routes = new RouteCollection,
$request = Request::create('http://www.foo.com/')
);
$url->setKeyResolver(function () {
$url->setKeyResolver(function (): string {
return 'secret';
});

$route = new Route(['GET'], 'foo', ['as' => 'foo', function () {
$route = new Route(['GET'], 'foo', ['as' => 'foo', function (): void {
}]);
$routes->add($route);

Expand All @@ -1173,6 +1173,16 @@ public function testSignedRelativeUrl()
$request = Request::create($url->signedRoute('foo', [], null, false) . '?tampered=true');

$this->assertFalse($url->hasValidSignature($request, false));

$routes->add(new Route(['GET'], '/', ['as' => 'home']));

$request = Request::create($url->signedRoute('home', [], null, false));

$this->assertTrue($url->hasValidRelativeSignature($request));

$request->query->set('signature', 'invalid');

$this->assertFalse($url->hasValidRelativeSignature($request));
}

public function testSignedUrlParameterCannotBeNamedSignature(): void
Expand Down
2 changes: 1 addition & 1 deletion tests/Support/SupportStrTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2283,7 +2283,7 @@ public function testResetFactoryState(): void

public function testPasswordCreation(): void
{
$this->assertTrue(strlen(Str::password()) === 32);
$this->assertSame(32, strlen(Str::password()));

$this->assertStringNotContainsString(' ', Str::password());
$this->assertStringContainsString(' ', Str::password(spaces: true));
Expand Down
Loading