From 80749a62a2abdc3b2f16ce3484f47c727f364de8 Mon Sep 17 00:00:00 2001 From: sak0a Date: Sat, 7 Feb 2026 08:39:43 +0100 Subject: [PATCH 1/7] Add GitHub repository configuration and register DevPullCommand - Introduced 'repository' configuration in notur.php for GitHub source code. - Registered DevPullCommand in NoturServiceProvider for enhanced development workflow. --- config/notur.php | 11 + src/Console/Commands/DevPullCommand.php | 395 ++++++++++++++++++++++++ src/NoturServiceProvider.php | 2 + 3 files changed, 408 insertions(+) create mode 100644 src/Console/Commands/DevPullCommand.php diff --git a/config/notur.php b/config/notur.php index 14773434..91681ca1 100644 --- a/config/notur.php +++ b/config/notur.php @@ -30,6 +30,17 @@ */ 'require_signatures' => false, + /* + |-------------------------------------------------------------------------- + | GitHub Repository + |-------------------------------------------------------------------------- + | + | The GitHub owner/repo for the Notur framework source code. + | Used by notur:dev:pull to download unreleased commits. + | + */ + 'repository' => 'sak0a/notur', + /* |-------------------------------------------------------------------------- | Registry URL diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php new file mode 100644 index 00000000..29e79614 --- /dev/null +++ b/src/Console/Commands/DevPullCommand.php @@ -0,0 +1,395 @@ +argument('branch'); + $commit = $this->argument('commit'); + $ref = $commit ?? $branch; + $isDryRun = (bool) $this->option('dry-run'); + $noRebuild = (bool) $this->option('no-rebuild'); + + $repo = config('notur.repository', self::DEFAULT_REPO); + $noturRoot = base_path('vendor/notur/notur'); + + $client = new Client([ + 'timeout' => 30, + 'connect_timeout' => 10, + 'headers' => [ + 'Accept' => 'application/vnd.github.v3+json', + 'User-Agent' => 'Notur-DevPull/1.0', + ], + ]); + + // Step 1: Fetch commit info + $this->info("Fetching commit info for '{$ref}' from {$repo}..."); + + try { + $commitInfo = $this->fetchCommitInfo($client, $repo, $ref); + } catch (\Throwable $e) { + $this->error("Failed to fetch commit info: {$e->getMessage()}"); + return 1; + } + + $sha = $commitInfo['sha']; + $shortSha = substr($sha, 0, 8); + $message = $commitInfo['commit']['message'] ?? 'No message'; + $author = $commitInfo['commit']['author']['name'] ?? 'Unknown'; + $date = $commitInfo['commit']['author']['date'] ?? 'Unknown'; + $firstLine = strtok($message, "\n"); + + $this->newLine(); + $this->info(" Branch: {$branch}"); + $this->info(" Commit: {$shortSha}"); + $this->info(" Author: {$author}"); + $this->info(" Date: {$date}"); + $this->info(" Message: {$firstLine}"); + $this->newLine(); + + // Step 2: Warn and confirm + $this->warn('This will replace files in vendor/notur/notur/ which is normally managed by Composer.'); + $this->warn('Running "composer update notur/notur" later will overwrite these changes.'); + + if ($isDryRun) { + $this->newLine(); + $this->info("[DRY RUN] Would download and extract commit {$shortSha} to {$noturRoot}"); + if (!$noRebuild) { + $this->info('[DRY RUN] Would rebuild frontend bridge'); + $this->info('[DRY RUN] Would copy bridge.js and tailwind.css to public/notur/'); + } + return 0; + } + + if (!$this->confirm("Pull commit {$shortSha} into vendor/notur/notur?")) { + $this->info('Aborted.'); + return 0; + } + + // Step 3: Download the zip archive + $tmpZip = sys_get_temp_dir() . '/notur-dev-pull-' . uniqid() . '.zip'; + $this->info("Downloading commit {$shortSha}..."); + + try { + $this->downloadArchive($client, $repo, $sha, $tmpZip); + } catch (\Throwable $e) { + $this->error("Download failed: {$e->getMessage()}"); + @unlink($tmpZip); + return 1; + } + + $this->info('Download complete.'); + + // Step 4: Extract to temp directory + $tmpDir = sys_get_temp_dir() . '/notur-dev-pull-extract-' . uniqid(); + + try { + $innerDir = $this->extractArchive($tmpZip, $tmpDir); + } catch (\Throwable $e) { + $this->error("Extraction failed: {$e->getMessage()}"); + @unlink($tmpZip); + return 1; + } + + @unlink($tmpZip); + + // Step 5: Replace vendor files + $this->info('Replacing vendor/notur/notur/...'); + + try { + $this->replaceVendorFiles($noturRoot, $innerDir); + } catch (\Throwable $e) { + $this->error("Failed to replace files: {$e->getMessage()}"); + $this->deleteDirectory($tmpDir); + return 1; + } + + $this->deleteDirectory($tmpDir); + $this->info('Files replaced successfully.'); + + // Step 6: Rebuild frontend + if (!$noRebuild) { + $this->newLine(); + $this->info('Rebuilding frontend...'); + + $packageManager = $this->resolvePackageManager($noturRoot); + + $result = $this->runProcess("{$packageManager} install", $noturRoot); + if ($result !== 0) { + $this->warn('Dependency install failed. You may need to run it manually.'); + } + + $result = $this->runProcess("{$packageManager} run build:bridge", $noturRoot); + if ($result !== 0) { + $this->warn('Bridge build failed. You may need to run it manually.'); + } + + $result = $this->runProcess("{$packageManager} run build:tailwind", $noturRoot); + if ($result !== 0) { + $this->warn('Tailwind build failed. You may need to run it manually.'); + } + + // Step 7: Copy built assets to public + $bridgeSource = $noturRoot . '/bridge/dist/bridge.js'; + $bridgeTarget = ExtensionPath::bridgeJs(); + + if (file_exists($bridgeSource) && !is_link($bridgeTarget)) { + $bridgeDir = dirname($bridgeTarget); + if (!is_dir($bridgeDir)) { + mkdir($bridgeDir, 0755, true); + } + copy($bridgeSource, $bridgeTarget); + $this->info('Copied bridge.js to public/notur/'); + } elseif (is_link($bridgeTarget)) { + $this->info('bridge.js is symlinked (dev mode) — skipping copy.'); + } + + $tailwindSource = $noturRoot . '/bridge/dist/tailwind.css'; + $tailwindTarget = ExtensionPath::tailwindCss(); + + if (file_exists($tailwindSource) && !is_link($tailwindTarget)) { + $tailwindDir = dirname($tailwindTarget); + if (!is_dir($tailwindDir)) { + mkdir($tailwindDir, 0755, true); + } + copy($tailwindSource, $tailwindTarget); + $this->info('Copied tailwind.css to public/notur/'); + } elseif (is_link($tailwindTarget)) { + $this->info('tailwind.css is symlinked (dev mode) — skipping copy.'); + } + } + + // Step 8: Clear caches + $this->call('cache:clear'); + $this->call('view:clear'); + + $this->newLine(); + $this->info("Notur framework updated to commit {$shortSha} ({$firstLine})"); + $this->info("Run 'composer update notur/notur' to revert to the published release."); + + return 0; + } + + private function fetchCommitInfo(Client $client, string $repo, string $ref): array + { + $url = self::GITHUB_API_BASE . "/repos/{$repo}/commits/{$ref}"; + + try { + $response = $client->get($url); + } catch (GuzzleException $e) { + throw new \RuntimeException( + "GitHub API request failed: {$e->getMessage()}", + (int) $e->getCode(), + $e, + ); + } + + $body = json_decode($response->getBody()->getContents(), true); + if (!is_array($body) || !isset($body['sha'])) { + throw new \RuntimeException('Unexpected response from GitHub API'); + } + + return $body; + } + + private function downloadArchive(Client $client, string $repo, string $sha, string $targetPath): void + { + $url = self::GITHUB_API_BASE . "/repos/{$repo}/zipball/{$sha}"; + + try { + $client->get($url, [ + 'sink' => $targetPath, + 'timeout' => 120, + 'connect_timeout' => 10, + ]); + } catch (GuzzleException $e) { + throw new \RuntimeException( + "Failed to download archive: {$e->getMessage()}", + (int) $e->getCode(), + $e, + ); + } + } + + private function extractArchive(string $zipPath, string $targetDir): string + { + if (!is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + $zip = new \ZipArchive(); + $result = $zip->open($zipPath); + + if ($result !== true) { + throw new \RuntimeException("Failed to open zip archive (error code: {$result})"); + } + + $zip->extractTo($targetDir); + $zip->close(); + + // GitHub zipball contains a single top-level directory: {owner}-{repo}-{shortsha}/ + $entries = array_values(array_filter( + scandir($targetDir), + fn ($e) => $e !== '.' && $e !== '..' && is_dir($targetDir . '/' . $e), + )); + + if (count($entries) !== 1) { + throw new \RuntimeException('Unexpected archive structure: expected exactly one top-level directory'); + } + + return $targetDir . '/' . $entries[0]; + } + + private function replaceVendorFiles(string $noturRoot, string $sourcePath): void + { + // Preserve vendor/ and node_modules/ if they exist (heavy install artifacts) + $preserveDirs = ['vendor', 'node_modules']; + $tmpPreserve = sys_get_temp_dir() . '/notur-preserve-' . uniqid(); + + foreach ($preserveDirs as $dir) { + $dirPath = $noturRoot . '/' . $dir; + if (is_dir($dirPath)) { + if (!is_dir($tmpPreserve)) { + mkdir($tmpPreserve, 0755, true); + } + rename($dirPath, $tmpPreserve . '/' . $dir); + } + } + + // Delete old vendor/notur/notur/ contents + if (is_dir($noturRoot)) { + $this->deleteDirectory($noturRoot); + } + + // Copy new files + $this->copyDirectory($sourcePath, $noturRoot); + + // Restore preserved directories + foreach ($preserveDirs as $dir) { + $tmpSource = $tmpPreserve . '/' . $dir; + if (is_dir($tmpSource)) { + $destPath = $noturRoot . '/' . $dir; + if (is_dir($destPath)) { + $this->deleteDirectory($destPath); + } + rename($tmpSource, $destPath); + } + } + + // Clean up preserve temp + if (is_dir($tmpPreserve)) { + $this->deleteDirectory($tmpPreserve); + } + } + + private function resolvePackageManager(string $cwd): string + { + if (file_exists($cwd . '/bun.lockb') || file_exists($cwd . '/bun.lock')) { + return 'bun'; + } + if (file_exists($cwd . '/pnpm-lock.yaml')) { + return 'pnpm'; + } + if (file_exists($cwd . '/yarn.lock')) { + return 'yarn'; + } + + return 'bun'; + } + + private function runProcess(string $command, string $cwd): int + { + $process = proc_open( + $command, + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + $cwd, + ); + + if (!is_resource($process)) { + return 1; + } + + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($output) { + $this->line($output); + } + if ($errors && $exitCode !== 0) { + $this->error($errors); + } + + return $exitCode; + } + + private function copyDirectory(string $source, string $dest): void + { + if (!is_dir($dest)) { + mkdir($dest, 0755, true); + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $item) { + $target = $dest . '/' . $iterator->getSubPathname(); + if ($item->isDir()) { + if (!is_dir($target)) { + mkdir($target, 0755, true); + } + } else { + copy($item->getPathname(), $target); + } + } + } + + private function deleteDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($dir); + } +} diff --git a/src/NoturServiceProvider.php b/src/NoturServiceProvider.php index c70df3d6..54b3450b 100644 --- a/src/NoturServiceProvider.php +++ b/src/NoturServiceProvider.php @@ -10,6 +10,7 @@ use Notur\Features\FeatureRegistry; use Notur\Console\Commands\BuildCommand; use Notur\Console\Commands\DevCommand; +use Notur\Console\Commands\DevPullCommand; use Notur\Console\Commands\DisableCommand; use Notur\Console\Commands\EnableCommand; use Notur\Console\Commands\ExportCommand; @@ -92,6 +93,7 @@ public function boot(): void ListCommand::class, UpdateCommand::class, DevCommand::class, + DevPullCommand::class, BuildCommand::class, ExportCommand::class, KeygenCommand::class, From cf01adb5a75dda71dfbf8c2a654ba53234eeb715 Mon Sep 17 00:00:00 2001 From: sak0a Date: Sat, 7 Feb 2026 17:44:09 +0100 Subject: [PATCH 2/7] Update version to 1.2.4 and add `notur:dev:pull` command for GitHub integration - Bumped framework version to 1.2.4 in configuration. - Introduced `notur:dev:pull` command to facilitate pulling updates from GitHub, including options for specific branches and dry-run functionality. - Added `repository` configuration key for GitHub source management. - Updated documentation to reflect new command and configuration changes. --- CLAUDE.md | 2 +- config/notur.php | 2 +- website/docs/admin/guide.md | 44 +++++++++++++++++++++++++++++ website/docs/reference/changelog.md | 16 +++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index af4cc80f..6272f103 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ npm run test:frontend # Jest tests **Database tables:** `notur_extensions`, `notur_migrations`, `notur_settings`, `notur_activity_logs` (migrations in `database/migrations/`). -**Artisan commands** (16 total in `src/Console/Commands/`): `notur:install`, `notur:remove`, `notur:enable`, `notur:disable`, `notur:list`, `notur:update`, `notur:dev`, `notur:build`, `notur:export`, `notur:registry:sync`, `notur:uninstall`, `notur:new`, `notur:validate`, `notur:status`, `notur:keygen`, `notur:registry:status`. +**Artisan commands** (17 total in `src/Console/Commands/`): `notur:install`, `notur:remove`, `notur:enable`, `notur:disable`, `notur:list`, `notur:update`, `notur:dev`, `notur:dev:pull`, `notur:build`, `notur:export`, `notur:registry:sync`, `notur:uninstall`, `notur:new`, `notur:validate`, `notur:status`, `notur:keygen`, `notur:registry:status`. ### Frontend Bridge (`bridge/src/`) diff --git a/config/notur.php b/config/notur.php index 91681ca1..bf4b4ad5 100644 --- a/config/notur.php +++ b/config/notur.php @@ -7,7 +7,7 @@ | Notur Version |-------------------------------------------------------------------------- */ - 'version' => '1.2.3', + 'version' => '1.2.4', /* |-------------------------------------------------------------------------- diff --git a/website/docs/admin/guide.md b/website/docs/admin/guide.md index cc828edf..9bb3ce89 100644 --- a/website/docs/admin/guide.md +++ b/website/docs/admin/guide.md @@ -230,6 +230,42 @@ php artisan notur:dev /path/to/my-extension --watch php artisan notur:dev /path/to/my-extension --watch --watch-bridge ``` +### Pulling Framework Updates from GitHub + +For developers working on the Notur framework itself, `notur:dev:pull` lets you quickly test unreleased commits without waiting for a new Composer release. It downloads the specified branch or commit from GitHub, replaces the files in `vendor/notur/notur/`, and rebuilds the frontend bridge automatically. + +```bash +# Pull the latest commit from master (default) +php artisan notur:dev:pull + +# Pull from a specific branch +php artisan notur:dev:pull develop + +# Pull a specific commit +php artisan notur:dev:pull master abc1234f + +# Preview what would happen without making changes +php artisan notur:dev:pull --dry-run + +# Pull without rebuilding the frontend bridge +php artisan notur:dev:pull --no-rebuild +``` + +The command will: +1. Fetch commit info from GitHub (SHA, author, date, message) +2. Ask for confirmation before proceeding +3. Download the zip archive of the exact commit +4. Replace files in `vendor/notur/notur/` (preserving `vendor/` and `node_modules/`) +5. Rebuild the bridge runtime and Tailwind CSS (unless `--no-rebuild`) +6. Copy `bridge.js` and `tailwind.css` to `public/notur/` +7. Clear caches + +::: warning +This modifies `vendor/notur/notur/` which is normally managed by Composer. Running `composer update notur/notur` will overwrite these changes and revert to the published release. +::: + +The GitHub repository is configured via the `notur.repository` config key (defaults to `sak0a/notur`). See [Configuration](#configuration) below. + ### Scaffolding New Extensions ```bash @@ -360,6 +396,14 @@ The directory where extensions are stored, relative to the panel root. Change th When `true`, only extensions with valid Ed25519 signatures can be installed. Set to `true` for production environments where you want to ensure extension integrity. Set to `false` for development or trusted environments. +### `repository` + +```php +'repository' => 'sak0a/notur', +``` + +The GitHub `owner/repo` for the Notur framework source code. Used by `notur:dev:pull` to download unreleased commits. Change this if you are working with a fork of the framework. + ### `registry_url` ```php diff --git a/website/docs/reference/changelog.md b/website/docs/reference/changelog.md index f4103ce6..e8168b72 100644 --- a/website/docs/reference/changelog.md +++ b/website/docs/reference/changelog.md @@ -2,6 +2,22 @@ All notable changes to the Notur Extension Library are documented here. +## [1.2.4] - 2026-02-07 + +### Added +- **`notur:dev:pull` command** -- Pull the latest Notur framework code directly from GitHub for development. Downloads a specific branch (default `master`) or commit via the GitHub API, replaces `vendor/notur/notur/`, and automatically rebuilds the frontend bridge. Supports `--no-rebuild` and `--dry-run` flags. +- **`repository` config key** -- New `notur.repository` setting in `config/notur.php` (defaults to `sak0a/notur`). Used by `notur:dev:pull` to resolve the GitHub repository. + +### Changed +- **Framework version bump to 1.2.4** -- Artisan command count increased from 16 to 17. +- **SDK unchanged** -- SDK package version remains unchanged for this release. + +## [1.2.3] - 2026-02-07 + +### Changed +- **Framework version bump to 1.2.3** -- Documentation and framework version references updated for the extension framework release. +- **SDK unchanged** -- SDK package version remains unchanged for this release. + ## [1.2.2] - 2026-02-07 ### Changed From 07a5b54fe66886c82bf05327372c1513c7b0ffbf Mon Sep 17 00:00:00 2001 From: saka Date: Sat, 7 Feb 2026 18:04:02 +0100 Subject: [PATCH 3/7] Fix package manager detection for npm --- src/Console/Commands/DevPullCommand.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 29e79614..370585d8 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -313,8 +313,11 @@ private function resolvePackageManager(string $cwd): string if (file_exists($cwd . '/yarn.lock')) { return 'yarn'; } + if (file_exists($cwd . '/package-lock.json')) { + return 'npm'; + } - return 'bun'; + return 'npm'; } private function runProcess(string $command, string $cwd): int From c1256beb3857a8cb2f72f9720ca6f2a544a30404 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:08:32 +0000 Subject: [PATCH 4/7] Initial plan From e46dd76ae1ac63491bc0a1cf4631d56dcb0fbd76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:14:40 +0000 Subject: [PATCH 5/7] Add path validation to prevent zip extraction path traversal attacks Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 106 +++++++++++++++++++++- tests/Unit/Console/DevPullCommandTest.php | 90 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Console/DevPullCommandTest.php diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index 370585d8..f83b8043 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -244,7 +244,62 @@ private function extractArchive(string $zipPath, string $targetDir): string throw new \RuntimeException("Failed to open zip archive (error code: {$result})"); } - $zip->extractTo($targetDir); + // Manually extract each entry with path validation to prevent path traversal attacks + $targetDir = realpath($targetDir); + if ($targetDir === false) { + $zip->close(); + throw new \RuntimeException('Failed to resolve target directory path'); + } + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false) { + continue; + } + + // Validate entry path: no absolute paths, no .. segments + if ($this->isUnsafePath($entry)) { + $zip->close(); + throw new \RuntimeException("Archive contains unsafe path: {$entry}"); + } + + $destination = $targetDir . DIRECTORY_SEPARATOR . $entry; + + // Double-check the resolved path is within target directory + $realDestination = realpath(dirname($destination)); + if ($realDestination === false) { + // Directory doesn't exist yet, which is OK - we'll create it + $realDestination = $this->normalizePath(dirname($destination)); + } + + if (!str_starts_with($realDestination, $targetDir)) { + $zip->close(); + throw new \RuntimeException("Archive attempts to extract outside target directory: {$entry}"); + } + + // Extract the entry + if (str_ends_with($entry, '/')) { + // Directory entry + if (!is_dir($destination)) { + mkdir($destination, 0755, true); + } + } else { + // File entry + $dir = dirname($destination); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $contents = $zip->getFromIndex($i); + if ($contents === false) { + $zip->close(); + throw new \RuntimeException("Failed to read entry from archive: {$entry}"); + } + + file_put_contents($destination, $contents); + } + } + $zip->close(); // GitHub zipball contains a single top-level directory: {owner}-{repo}-{shortsha}/ @@ -260,6 +315,55 @@ private function extractArchive(string $zipPath, string $targetDir): string return $targetDir . '/' . $entries[0]; } + /** + * Check if a path contains unsafe components that could lead to path traversal. + */ + private function isUnsafePath(string $path): bool + { + // Check for absolute paths (Unix or Windows style) + if (str_starts_with($path, '/') || preg_match('/^[a-zA-Z]:/', $path)) { + return true; + } + + // Check for .. segments or null bytes + $parts = explode('/', str_replace('\\', '/', $path)); + foreach ($parts as $part) { + if ($part === '..' || str_contains($part, "\0")) { + return true; + } + } + + return false; + } + + /** + * Normalize a path by resolving . and .. segments without requiring the path to exist. + */ + private function normalizePath(string $path): string + { + $path = str_replace('\\', '/', $path); + $parts = explode('/', $path); + $normalized = []; + + foreach ($parts as $part) { + if ($part === '' || $part === '.') { + continue; + } + if ($part === '..') { + array_pop($normalized); + } else { + $normalized[] = $part; + } + } + + $result = implode(DIRECTORY_SEPARATOR, $normalized); + if (str_starts_with($path, '/')) { + $result = DIRECTORY_SEPARATOR . $result; + } + + return $result; + } + private function replaceVendorFiles(string $noturRoot, string $sourcePath): void { // Preserve vendor/ and node_modules/ if they exist (heavy install artifacts) diff --git a/tests/Unit/Console/DevPullCommandTest.php b/tests/Unit/Console/DevPullCommandTest.php new file mode 100644 index 00000000..23d616b4 --- /dev/null +++ b/tests/Unit/Console/DevPullCommandTest.php @@ -0,0 +1,90 @@ +getMethod($methodName); + $method->setAccessible(true); + + return $method->invokeArgs($object, $args); + } + + public function testIsUnsafePathDetectsAbsolutePaths(): void + { + $command = new DevPullCommand(); + + // Unix absolute paths + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ['/etc/passwd'])); + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ['/tmp/evil'])); + + // Windows absolute paths + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ['C:/Windows/System32'])); + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ['D:/data'])); + } + + public function testIsUnsafePathDetectsParentDirectoryTraversal(): void + { + $command = new DevPullCommand(); + + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ['../etc/passwd'])); + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ['foo/../../etc/passwd'])); + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ['foo/../../../bar'])); + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ['..'])); + } + + public function testIsUnsafePathDetectsNullBytes(): void + { + $command = new DevPullCommand(); + + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ["foo\0bar"])); + $this->assertTrue($this->invokePrivateMethod($command, 'isUnsafePath', ["dir/file\0.txt"])); + } + + public function testIsUnsafePathAllowsSafePaths(): void + { + $command = new DevPullCommand(); + + $this->assertFalse($this->invokePrivateMethod($command, 'isUnsafePath', ['src/Console/Commands/DevPullCommand.php'])); + $this->assertFalse($this->invokePrivateMethod($command, 'isUnsafePath', ['foo/bar/baz.txt'])); + $this->assertFalse($this->invokePrivateMethod($command, 'isUnsafePath', ['file.txt'])); + $this->assertFalse($this->invokePrivateMethod($command, 'isUnsafePath', ['a/b/c/d/e.json'])); + } + + public function testNormalizePathResolvesRelativeComponents(): void + { + $command = new DevPullCommand(); + + $this->assertEquals( + 'foo' . DIRECTORY_SEPARATOR . 'bar', + $this->invokePrivateMethod($command, 'normalizePath', ['foo/./bar']) + ); + + $this->assertEquals( + 'foo' . DIRECTORY_SEPARATOR . 'baz', + $this->invokePrivateMethod($command, 'normalizePath', ['foo/bar/../baz']) + ); + + $this->assertEquals( + 'baz', + $this->invokePrivateMethod($command, 'normalizePath', ['foo/bar/../../baz']) + ); + } + + public function testNormalizePathPreservesLeadingSlash(): void + { + $command = new DevPullCommand(); + + $result = $this->invokePrivateMethod($command, 'normalizePath', ['/foo/bar']); + $this->assertTrue(str_starts_with($result, DIRECTORY_SEPARATOR)); + } +} From 821b0f0211fdd4a000dcf1eb2d4a70863cc86a35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:15:50 +0000 Subject: [PATCH 6/7] Improve path validation logic and add tests for edge cases Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 30 +++++++++++++++++------ tests/Unit/Console/DevPullCommandTest.php | 18 ++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index f83b8043..aaf8f69c 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -263,16 +263,26 @@ private function extractArchive(string $zipPath, string $targetDir): string throw new \RuntimeException("Archive contains unsafe path: {$entry}"); } - $destination = $targetDir . DIRECTORY_SEPARATOR . $entry; + // Validate that the normalized relative path doesn't escape + try { + $normalizedEntry = $this->normalizePath($entry); + } catch (\RuntimeException $e) { + $zip->close(); + throw new \RuntimeException("Archive contains path that attempts to escape: {$entry}"); + } + + $destination = $targetDir . DIRECTORY_SEPARATOR . $normalizedEntry; // Double-check the resolved path is within target directory $realDestination = realpath(dirname($destination)); if ($realDestination === false) { - // Directory doesn't exist yet, which is OK - we'll create it - $realDestination = $this->normalizePath(dirname($destination)); - } - - if (!str_starts_with($realDestination, $targetDir)) { + // Directory doesn't exist yet, which is OK - verify the path would be safe + $parentPath = dirname($destination); + if (!str_starts_with($parentPath, $targetDir)) { + $zip->close(); + throw new \RuntimeException("Archive attempts to extract outside target directory: {$entry}"); + } + } elseif (!str_starts_with($realDestination, $targetDir)) { $zip->close(); throw new \RuntimeException("Archive attempts to extract outside target directory: {$entry}"); } @@ -281,13 +291,13 @@ private function extractArchive(string $zipPath, string $targetDir): string if (str_ends_with($entry, '/')) { // Directory entry if (!is_dir($destination)) { - mkdir($destination, 0755, true); + mkdir($destination, 0750, true); } } else { // File entry $dir = dirname($destination); if (!is_dir($dir)) { - mkdir($dir, 0755, true); + mkdir($dir, 0750, true); } $contents = $zip->getFromIndex($i); @@ -350,6 +360,10 @@ private function normalizePath(string $path): string continue; } if ($part === '..') { + // If we can't pop (empty array), the path tries to escape - reject it + if (empty($normalized)) { + throw new \RuntimeException('Invalid path: attempts to traverse above root'); + } array_pop($normalized); } else { $normalized[] = $part; diff --git a/tests/Unit/Console/DevPullCommandTest.php b/tests/Unit/Console/DevPullCommandTest.php index 23d616b4..a856f268 100644 --- a/tests/Unit/Console/DevPullCommandTest.php +++ b/tests/Unit/Console/DevPullCommandTest.php @@ -87,4 +87,22 @@ public function testNormalizePathPreservesLeadingSlash(): void $result = $this->invokePrivateMethod($command, 'normalizePath', ['/foo/bar']); $this->assertTrue(str_starts_with($result, DIRECTORY_SEPARATOR)); } + + public function testNormalizePathRejectsPathsEscapingRoot(): void + { + $command = new DevPullCommand(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('attempts to traverse above root'); + $this->invokePrivateMethod($command, 'normalizePath', ['../../etc/passwd']); + } + + public function testNormalizePathRejectsMultipleEscapeAttempts(): void + { + $command = new DevPullCommand(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('attempts to traverse above root'); + $this->invokePrivateMethod($command, 'normalizePath', ['../../../system']); + } } From bcb96d95973f3324e987197ad92e8e8aa73673ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:16:52 +0000 Subject: [PATCH 7/7] Enhance security with better symlink handling and error checking Co-authored-by: sak0a <24781653+sak0a@users.noreply.github.com> --- src/Console/Commands/DevPullCommand.php | 41 +++++++++++++++++-------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/Console/Commands/DevPullCommand.php b/src/Console/Commands/DevPullCommand.php index aaf8f69c..e8424b88 100644 --- a/src/Console/Commands/DevPullCommand.php +++ b/src/Console/Commands/DevPullCommand.php @@ -245,12 +245,16 @@ private function extractArchive(string $zipPath, string $targetDir): string } // Manually extract each entry with path validation to prevent path traversal attacks - $targetDir = realpath($targetDir); - if ($targetDir === false) { + $realTargetDir = realpath($targetDir); + if ($realTargetDir === false) { $zip->close(); throw new \RuntimeException('Failed to resolve target directory path'); } + // Use the original (non-symlink-resolved) path for security checks + // to prevent symlink-based attacks + $secureTargetDir = rtrim($targetDir, '/\\'); + for ($i = 0; $i < $zip->numFiles; $i++) { $entry = $zip->getNameIndex($i); if ($entry === false) { @@ -271,18 +275,25 @@ private function extractArchive(string $zipPath, string $targetDir): string throw new \RuntimeException("Archive contains path that attempts to escape: {$entry}"); } - $destination = $targetDir . DIRECTORY_SEPARATOR . $normalizedEntry; + $destination = $secureTargetDir . DIRECTORY_SEPARATOR . $normalizedEntry; // Double-check the resolved path is within target directory - $realDestination = realpath(dirname($destination)); - if ($realDestination === false) { - // Directory doesn't exist yet, which is OK - verify the path would be safe - $parentPath = dirname($destination); - if (!str_starts_with($parentPath, $targetDir)) { + // Normalize the parent path to detect any traversal attempts + $parentPath = dirname($destination); + try { + $normalizedParent = $this->normalizePath($parentPath); + if (!str_starts_with($normalizedParent, $secureTargetDir)) { $zip->close(); throw new \RuntimeException("Archive attempts to extract outside target directory: {$entry}"); } - } elseif (!str_starts_with($realDestination, $targetDir)) { + } catch (\RuntimeException $e) { + $zip->close(); + throw new \RuntimeException("Archive contains path that attempts to escape: {$entry}"); + } + + // Also verify with realpath if the directory exists + $realParent = realpath($parentPath); + if ($realParent !== false && !str_starts_with($realParent, $realTargetDir)) { $zip->close(); throw new \RuntimeException("Archive attempts to extract outside target directory: {$entry}"); } @@ -306,7 +317,11 @@ private function extractArchive(string $zipPath, string $targetDir): string throw new \RuntimeException("Failed to read entry from archive: {$entry}"); } - file_put_contents($destination, $contents); + $written = file_put_contents($destination, $contents); + if ($written === false) { + $zip->close(); + throw new \RuntimeException("Failed to write extracted file: {$entry}"); + } } } @@ -314,15 +329,15 @@ private function extractArchive(string $zipPath, string $targetDir): string // GitHub zipball contains a single top-level directory: {owner}-{repo}-{shortsha}/ $entries = array_values(array_filter( - scandir($targetDir), - fn ($e) => $e !== '.' && $e !== '..' && is_dir($targetDir . '/' . $e), + scandir($secureTargetDir), + fn ($e) => $e !== '.' && $e !== '..' && is_dir($secureTargetDir . '/' . $e), )); if (count($entries) !== 1) { throw new \RuntimeException('Unexpected archive structure: expected exactly one top-level directory'); } - return $targetDir . '/' . $entries[0]; + return $secureTargetDir . '/' . $entries[0]; } /**