feat(vikon): add atomicSwap to FilesystemTask for crash-safe updates
This commit is contained in:
@@ -64,6 +64,64 @@ class FilesystemTask
|
|||||||
return File::deleteDirectory($source);
|
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
|
public function validateFileTypes(string $directory): array
|
||||||
{
|
{
|
||||||
$allowed = [
|
$allowed = [
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\VikonIntegration\Tests\Unit;
|
||||||
|
|
||||||
|
use App\Containers\VikonIntegration\Tasks\FilesystemTask;
|
||||||
|
use App\Ship\Tests\TestCase;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
class FilesystemTaskTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $tempDir;
|
||||||
|
private FilesystemTask $fs;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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', '<?php echo "old";');
|
||||||
|
|
||||||
|
$newDir = $this->tempDir . '/extracted/common';
|
||||||
|
File::makeDirectory($newDir, 0755, true, true);
|
||||||
|
file_put_contents($newDir . '/index.php', '<?php echo "new";');
|
||||||
|
|
||||||
|
$result = $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user