From a624b83f18dcf0446536c9fb9f125a1854378848 Mon Sep 17 00:00:00 2001 From: F4ilji Date: Sun, 5 Jul 2026 02:35:25 +0500 Subject: [PATCH] feat(vikon): add atomicSwap to FilesystemTask for crash-safe updates --- .../VikonIntegration/Tasks/FilesystemTask.php | 58 +++++++++++++ .../Tests/Unit/FilesystemTaskTest.php | 85 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php diff --git a/app/Containers/VikonIntegration/Tasks/FilesystemTask.php b/app/Containers/VikonIntegration/Tasks/FilesystemTask.php index 96d0a64..312c2fc 100644 --- a/app/Containers/VikonIntegration/Tasks/FilesystemTask.php +++ b/app/Containers/VikonIntegration/Tasks/FilesystemTask.php @@ -64,6 +64,64 @@ class FilesystemTask return File::deleteDirectory($source); } + public function atomicSwap( + string $newEntryPath, + string $currentEntryPath, + string $baseDir, + int $moduleId + ): bool { + if (!$this->isPathSafe($currentEntryPath, $baseDir)) { + return false; + } + + if (!File::exists($newEntryPath)) { + return false; + } + + $isFile = is_file($newEntryPath); + $newPostfix = $currentEntryPath . '_new'; + $oldPostfix = $currentEntryPath . '_old'; + + if (File::exists($currentEntryPath)) { + // Step 1: Remove stale _new if exists + if (File::exists($newPostfix)) { + $isFile ? File::delete($newPostfix) : File::deleteDirectory($newPostfix); + } + + // Step 2: Move new → _new + if (!$this->moveEntry($newEntryPath, $newPostfix, $isFile)) { + return false; + } + + // Step 3: Remove stale _old if exists + if (File::exists($oldPostfix)) { + $isFile ? File::delete($oldPostfix) : File::deleteDirectory($oldPostfix); + } + + // Step 4: Move current → _old + if (!$this->moveEntry($currentEntryPath, $oldPostfix, $isFile)) { + return false; + } + + // Step 5: Move _new → current + return $this->moveEntry($newPostfix, $currentEntryPath, $isFile); + } + + // Entry doesn't exist — move directly + return $this->moveEntry($newEntryPath, $currentEntryPath, $isFile); + } + + private function moveEntry(string $source, string $dest, bool $isFile): bool + { + if ($isFile) { + $parent = dirname($dest); + if (!File::isDirectory($parent)) { + File::makeDirectory($parent, 0755, true, true); + } + } + return rename($source, $dest); + } + public function validateFileTypes(string $directory): array { $allowed = [ diff --git a/app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php b/app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php new file mode 100644 index 0000000..5ee407d --- /dev/null +++ b/app/Containers/VikonIntegration/Tests/Unit/FilesystemTaskTest.php @@ -0,0 +1,85 @@ +tempDir = sys_get_temp_dir() . '/vikon_test_' . uniqid(); + File::makeDirectory($this->tempDir, 0755, true, true); + $this->fs = new FilesystemTask(); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->tempDir); + parent::tearDown(); + } + + public function test_atomic_swap_replaces_existing_entry(): void + { + $base = $this->tempDir . '/module'; + File::makeDirectory($base . '/common', 0755, true, true); + + file_put_contents($base . '/common/index.php', 'tempDir . '/extracted/common'; + File::makeDirectory($newDir, 0755, true, true); + file_put_contents($newDir . '/index.php', 'fs->atomicSwap( + $newDir, + $base . '/common', + $base, + 1 + ); + + $this->assertTrue($result); + $this->assertFileExists($base . '/common/index.php'); + $this->assertStringContainsString('new', file_get_contents($base . '/common/index.php')); + $this->assertDirectoryExists($base . '/common_old'); + $this->assertDirectoryDoesNotExist($base . '/common_new'); + } + + public function test_atomic_swap_creates_new_entry_when_not_exists(): void + { + $base = $this->tempDir . '/module'; + File::makeDirectory($base, 0755, true, true); + + $newDir = $this->tempDir . '/extracted/assets'; + File::makeDirectory($newDir, 0755, true, true); + file_put_contents($newDir . '/style.css', 'body {}'); + + $result = $this->fs->atomicSwap( + $newDir, + $base . '/assets', + $base, + 1 + ); + + $this->assertTrue($result); + $this->assertFileExists($base . '/assets/style.css'); + $this->assertDirectoryDoesNotExist($base . '/assets_old'); + } + + public function test_atomic_swap_returns_false_on_path_traversal(): void + { + $result = $this->fs->atomicSwap( + '/etc/passwd', + $this->tempDir . '/module/etc', + $this->tempDir . '/module', + 1 + ); + + $this->assertFalse($result); + } +}