diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ff7eeb8c44..1c36b36298 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,7 +8,10 @@ use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use App\Models\Server; use App\Rules\SafeExternalUrl; +use App\Support\AtomicFilesystem; use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Console\ConfigCacheCommand; +use Illuminate\Foundation\Console\RouteCacheCommand; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; @@ -25,6 +28,10 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->bind(StripeClient::class, fn () => new StripeClient(config('subscription.stripe_api_key'))); + + // Requests must never read a partly written cache file. See AtomicFilesystem. + $this->app->extend(ConfigCacheCommand::class, fn () => new ConfigCacheCommand(new AtomicFilesystem)); + $this->app->extend(RouteCacheCommand::class, fn () => new RouteCacheCommand(new AtomicFilesystem)); } public function boot(): void diff --git a/app/Support/AtomicFilesystem.php b/app/Support/AtomicFilesystem.php new file mode 100644 index 0000000000..321b5aa584 --- /dev/null +++ b/app/Support/AtomicFilesystem.php @@ -0,0 +1,51 @@ +directory = sys_get_temp_dir().'/atomic-filesystem-'.bin2hex(random_bytes(4)); + mkdir($this->directory); +}); + +afterEach(function () { + array_map('unlink', glob($this->directory.'/*')); + rmdir($this->directory); +}); + +it('writes the config and route caches with the atomic filesystem', function (string $command) { + $files = (fn () => $this->files)->call(app($command)); + + expect($files)->toBeInstanceOf(AtomicFilesystem::class); +})->with([ + 'config:cache' => ConfigCacheCommand::class, + 'route:cache' => RouteCacheCommand::class, +]); + +it('replaces the file with a new complete file and leaves no temporary file', function () { + $path = $this->directory.'/config.php'; + file_put_contents($path, 'put($path, " ['name' => 'Coolify']];"); + } finally { + umask($previousUmask); + } + + clearstatcache(); + expect($bytes)->toBe(46) + ->and(require $path)->toBe(['app' => ['name' => 'Coolify']]) + ->and(fileinode($path))->not->toBe($oldInode) + ->and(fileperms($path) & 0777)->toBe(0644) + ->and(glob($this->directory.'/*'))->toBe([$path]); +}); + +it('does not write the file outside its directory', function () { + $path = $this->directory.'/missing/config.php'; + + expect(fn () => (new AtomicFilesystem)->put($path, 'toThrow(RuntimeException::class, 'Could not create a temporary file'); + + expect(file_exists($path))->toBeFalse(); +});