├── .github
└── workflows
│ ├── check-cs.yml
│ ├── phpstan.yml
│ └── unit-tests.yml
├── .gitignore
├── .php-cs-fixer.php
├── CHANGELOG.md
├── LICENSE
├── README.md
├── UPGRADE.md
├── bin
└── xdebug-proxy
├── composer.json
├── composer.lock
├── config
├── config.php
├── factory.php
└── logger.php
├── grumphp.yml
├── phpstan.neon
├── phpunit.xml
├── softMocksConfig
├── config.php
├── factory.php
└── logger.php
├── src
├── Config
│ ├── Config.php
│ ├── IdeServer.php
│ ├── Server.php
│ ├── SoftMocks.php
│ └── SoftMocksConfig.php
├── Enum
│ ├── RegistrationCommand.php
│ └── RegistrationError.php
├── Factory
│ ├── DefaultFactory.php
│ ├── Factory.php
│ └── SoftMocksFactory.php
├── Handler
│ ├── CommandToXdebugParser.php
│ ├── DefaultIdeHandler.php
│ ├── DefaultXdebugHandler.php
│ ├── Exception.php
│ ├── FromXdebugProcessError.php
│ ├── FromXdebugProcessException.php
│ ├── Handler.php
│ ├── IdeHandler.php
│ ├── IdeRegistrationException.php
│ └── XdebugHandler.php
├── LoggerFormatter.php
├── Proxy.php
├── RequestPreparer
│ ├── Error.php
│ ├── Exception.php
│ ├── RequestPreparer.php
│ └── SoftMocksRequestPreparer.php
├── RunError.php
├── Runner.php
└── Xml
│ ├── DomXmlConverter.php
│ ├── XmlContainer.php
│ ├── XmlConverter.php
│ ├── XmlDocument.php
│ ├── XmlException.php
│ ├── XmlParseException.php
│ └── XmlValidateException.php
└── tests
├── TestCase.php
├── Unit
└── Xml
│ └── DomXmlConverterTest.php
└── bootstrap.php
/.github/workflows/check-cs.yml:
--------------------------------------------------------------------------------
1 | name: Check coding standards
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | php-cs-fixer:
7 | name: Check coding standards
8 | runs-on: ubuntu-24.04
9 | steps:
10 | - name: Checkout code
11 | uses: actions/checkout@v2
12 |
13 | - name: Setup PHP
14 | uses: shivammathur/setup-php@v2
15 | with:
16 | coverage: "none"
17 | ini-values: memory_limit=-1,apc.enable_cli=1,zend.assertions=1
18 | php-version: "8.1"
19 | tools: flex
20 |
21 | - name: Install dependencies
22 | run: composer install
23 |
24 | - name: PHP CS Fixer
25 | run: vendor/bin/php-cs-fixer --allow-risky=yes --config=.php-cs-fixer.php --verbose fix --dry-run --diff
26 |
--------------------------------------------------------------------------------
/.github/workflows/phpstan.yml:
--------------------------------------------------------------------------------
1 | name: PHPStan Static analysis
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | psalm:
7 | name: PHPStan
8 | runs-on: ubuntu-24.04
9 | steps:
10 | - name: Checkout code
11 | uses: actions/checkout@v2
12 |
13 | - name: Setup PHP
14 | uses: shivammathur/setup-php@v2
15 | with:
16 | coverage: "none"
17 | ini-values: memory_limit=-1,apc.enable_cli=1,zend.assertions=1
18 | php-version: "8.1"
19 | tools: flex
20 |
21 | - name: Install dependencies
22 | run: composer install
23 |
24 | - name: "Restore result cache"
25 | uses: actions/cache/restore@v4
26 | with:
27 | path: tmp # same as in phpstan.neon
28 | key: "phpstan-result-cache-${{ github.run_id }}"
29 | restore-keys: |
30 | phpstan-result-cache-
31 |
32 | - name: "Run PHPStan"
33 | run: "vendor/bin/phpstan"
34 |
35 | - name: "Save result cache"
36 | uses: actions/cache/save@v4
37 | if: always()
38 | with:
39 | path: tmp # same as in phpstan.neon
40 | key: "phpstan-result-cache-${{ github.run_id }}"
41 |
--------------------------------------------------------------------------------
/.github/workflows/unit-tests.yml:
--------------------------------------------------------------------------------
1 | name: Unit tests
2 |
3 | on: [push, pull_request]
4 |
5 | defaults:
6 | run:
7 | shell: bash
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | tests:
14 | name: Tests
15 |
16 | strategy:
17 | matrix:
18 | include:
19 | - php: '8.4'
20 | - php: '8.3'
21 | - php: '8.2'
22 | - php: '8.1'
23 | fail-fast: false
24 |
25 | runs-on: ubuntu-24.04
26 |
27 | steps:
28 | - name: Checkout
29 | uses: actions/checkout@v2
30 |
31 | - name: Setup PHP
32 | uses: shivammathur/setup-php@v2
33 | with:
34 | coverage: "none"
35 | ini-values: memory_limit=-1,apc.enable_cli=1,zend.assertions=1
36 | php-version: "${{ matrix.php }}"
37 | tools: flex
38 |
39 | - name: Install dependencies
40 | run: composer install
41 |
42 | - name: Run tests
43 | run: vendor/bin/phpunit --colors=always
44 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor/
2 | /.php-cs-fixer.cache
3 | /.phpunit.result.cache
4 | /tmp
5 |
--------------------------------------------------------------------------------
/.php-cs-fixer.php:
--------------------------------------------------------------------------------
1 | setRiskyAllowed(true)
10 | ->setRules([
11 | '@Symfony' => true,
12 | 'concat_space' => ['spacing' => 'one'],
13 | 'global_namespace_import' => ['import_classes' => true, 'import_constants' => true, 'import_functions' => true],
14 | 'declare_strict_types' => true,
15 | 'ordered_imports' => [
16 | 'imports_order' => [
17 | 'class',
18 | 'const',
19 | 'function',
20 | ],
21 | ],
22 | 'strict_comparison' => true,
23 | 'combine_consecutive_unsets' => true,
24 | 'dir_constant' => true,
25 | 'ereg_to_preg' => true,
26 | 'modernize_types_casting' => true,
27 | 'multiline_whitespace_before_semicolons' => false,
28 | 'no_php4_constructor' => true,
29 | 'no_useless_else' => true,
30 | 'no_useless_return' => true,
31 | 'phpdoc_add_missing_param_annotation' => true,
32 | 'phpdoc_order' => ['order' => ['param', 'return', 'throws']],
33 | 'strict_param' => true,
34 | 'phpdoc_to_comment' => false,
35 | 'phpdoc_align' => false,
36 | 'yoda_style' => false,
37 | 'increment_style' => false,
38 | 'phpdoc_no_empty_return' => false,
39 | 'single_line_throw' => false,
40 | 'blank_line_before_statement' => false,
41 | 'phpdoc_separation' => [
42 | 'groups' => [
43 | ['Annotation', 'NamedArgumentConstructor', 'Target'],
44 | ['author', 'copyright', 'license'],
45 | ['category', 'package', 'subpackage'],
46 | ['property', 'property-read', 'property-write'],
47 | ['deprecated', 'link', 'see', 'since'],
48 | ['return', 'phpstan-return', 'param', 'phpstan-param'],
49 | ],
50 | ],
51 | ])
52 | ->setFinder(
53 | Finder::create()
54 | ->in(__DIR__)
55 | ->exclude(['tmp', 'vendor'])
56 | )
57 | ;
58 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## PHP xdebug (dbgp) proxy ChangeLog
2 |
3 | ### [Unreleased]
4 |
5 | There are next changes:
6 |
7 | ### [v0.6.0]
8 |
9 | There are next changes:
10 | - Update minimum php version from 7.4 to 8.1 ([#44](https://github.com/mougrim/php-xdebug-proxy/pull/44))
11 | - Remove psalm, now phpstan is used ([#44](https://github.com/mougrim/php-xdebug-proxy/pull/44))
12 |
13 | ### [v0.5.1]
14 |
15 | There are next changes:
16 |
17 | - Bump actions/download-artifact from 2 to 4.1.7 in /.github/workflows ([#41](https://github.com/mougrim/php-xdebug-proxy/pull/41))
18 | - Bump symfony/process from 5.4.3 to 5.4.46 ([#42](https://github.com/mougrim/php-xdebug-proxy/pull/42))
19 |
20 | ### [v0.5.0]
21 |
22 | There are next changes:
23 |
24 | - Minimum PHP version supported upgraded to 7.4 ([#29](https://github.com/mougrim/php-xdebug-proxy/pull/29))
25 | - PHPUnit version upgraded to 9 ([#29](https://github.com/mougrim/php-xdebug-proxy/pull/29))
26 | - Add run unit tests Github action ([#30](https://github.com/mougrim/php-xdebug-proxy/pull/30), [#31](https://github.com/mougrim/php-xdebug-proxy/pull/31))
27 | - Remove travis ([#30](https://github.com/mougrim/php-xdebug-proxy/pull/30))
28 | - Fix code style issues ([#32](https://github.com/mougrim/php-xdebug-proxy/pull/32))
29 | - Fix by workaround grumphp issue with TypeError, see [phpro/grumphp#957](https://github.com/phpro/grumphp/issues/957) ([#32](https://github.com/mougrim/php-xdebug-proxy/pull/32))
30 | - Update friendsofphp/php-cs-fixer to 3 ([#33](https://github.com/mougrim/php-xdebug-proxy/pull/33))
31 | - Add psalm ([#34](https://github.com/mougrim/php-xdebug-proxy/pull/34), [#35](https://github.com/mougrim/php-xdebug-proxy/pull/35))
32 | - Add check cs github action ([#36](https://github.com/mougrim/php-xdebug-proxy/pull/36))
33 |
34 | ### [v0.4.1]
35 |
36 | There are next changes:
37 |
38 | - Fix changes in grumphp and bad composer.lock ([#26](https://github.com/mougrim/php-xdebug-proxy/pull/26))
39 |
40 | ### [v0.4.0]
41 |
42 | There are next changes:
43 |
44 | - next methods were added ([#21](https://github.com/mougrim/php-xdebug-proxy/pull/21)):
45 | - `\Mougrim\XdebugProxy\Xml\XmlDocument::toArray()`
46 | - `\Mougrim\XdebugProxy\Xml\XmlContainer::toArray()`
47 | - `\Mougrim\XdebugProxy\Xml\XmlContainer::getAttribute()`
48 | - [config](softMocksConfig) for soft-mocks was added ([#22](https://github.com/mougrim/php-xdebug-proxy/pull/22))
49 | - parameter $config was added to method `\Mougrim\XdebugProxy\Factory\Factory::createRequestPreparers()` ([#22](https://github.com/mougrim/php-xdebug-proxy/pull/22))
50 | - now `\Mougrim\XdebugProxy\Factory\SoftMocksFactory::createConfig()` should return `\Mougrim\XdebugProxy\Config\SoftMocksConfig` ([#22](https://github.com/mougrim/php-xdebug-proxy/pull/22))
51 |
52 | ### [v0.3.0]
53 |
54 | There are next changes:
55 |
56 | - there were code style fixes ([#14](https://github.com/mougrim/php-xdebug-proxy/pull/14))
57 | - more info about default IDE in config was added to README.md ([#15](https://github.com/mougrim/php-xdebug-proxy/pull/15))
58 | - now request preparers are called on request to xdebug from last to first ([#16](https://github.com/mougrim/php-xdebug-proxy/pull/16))
59 | - minimum php version now is 7.1 ([#18](https://github.com/mougrim/php-xdebug-proxy/pull/18))
60 | - constants visibility was added ([#18](https://github.com/mougrim/php-xdebug-proxy/pull/18))
61 | - deprecated interface \Mougrim\XdebugProxy\RequestPreparer was removed ([#18](https://github.com/mougrim/php-xdebug-proxy/pull/18))
62 |
63 | ### [v0.2.1]
64 |
65 | There are next changes:
66 |
67 | - possibility to disable IDE registration was added ([#12](https://github.com/mougrim/php-xdebug-proxy/pull/12))
68 |
69 | ### [v0.2.0]
70 |
71 | There are next changes:
72 |
73 | - defaultIde config param was added ([#8](https://github.com/mougrim/php-xdebug-proxy/pull/8))
74 | - predefinedIdeList config param was added ([#8](https://github.com/mougrim/php-xdebug-proxy/pull/8))
75 | - now by default debug and info logs are disabled ([#9](https://github.com/mougrim/php-xdebug-proxy/pull/9))
76 | - log levels for some logs are changed ([#9](https://github.com/mougrim/php-xdebug-proxy/pull/9))
77 | - [README.md](README.md) was updated ([#11](https://github.com/mougrim/php-xdebug-proxy/pull/11))
78 | - doc for [`RequestPreparer\RequestPreparer`](src/RequestPreparer/RequestPreparer.php) was added ([#11](https://github.com/mougrim/php-xdebug-proxy/pull/11))
79 |
80 | ### [v0.1.0]
81 |
82 | There are next changes:
83 |
84 | - [soft-mocks](https://github.com/badoo/soft-mocks/#using-with-xdebug) support was added ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7))
85 | - `\Mougrim\XdebugProxy\Handler\CommandToXdebugParser::buildCommand()` method was added ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7))
86 | - now `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer` is used instead of `\Mougrim\XdebugProxy\RequestPreparer` ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7))
87 | - `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer` methods can throw `\Mougrim\XdebugProxy\RequestPreparer\Error` and `\Mougrim\XdebugProxy\RequestPreparer\Exception` if there is some problem ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7))
88 | - now `\Mougrim\XdebugProxy\Factory\Factory::createRequestPreparers()` accepts `$logger` param and also can throw `\Mougrim\XdebugProxy\RequestPreparer\Error` and `\Mougrim\XdebugProxy\RequestPreparer\Exception` if there is some problem ([#7](https://github.com/mougrim/php-xdebug-proxy/pull/7))
89 |
90 | [unreleased]: https://github.com/mougrim/php-xdebug-proxy/compare/0.6.0...HEAD
91 | [v0.6.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.5.1...0.6.0
92 | [v0.5.1]: https://github.com/mougrim/php-xdebug-proxy/compare/0.5.0...0.5.1
93 | [v0.5.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.4.1...0.5.0
94 | [v0.4.1]: https://github.com/mougrim/php-xdebug-proxy/compare/0.4.0...0.4.1
95 | [v0.4.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.3.0...0.4.0
96 | [v0.3.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.2.1...0.3.0
97 | [v0.2.1]: https://github.com/mougrim/php-xdebug-proxy/compare/0.2.0...0.2.1
98 | [v0.2.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.1.0...0.2.0
99 | [v0.1.0]: https://github.com/mougrim/php-xdebug-proxy/compare/0.0.1...0.1.0
100 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Mougrim
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## PHP xdebug (dbgp) proxy
2 |
3 | This is [expandable](#extending) [dbgp](https://xdebug.org/docs-dbgp.php) xdebug proxy based on [amphp](https://amphp.org/).
4 |
5 | The idea is described in the document [Multi-user debugging in PhpStorm with Xdebug and DBGp proxy](https://confluence.jetbrains.com/display/PhpStorm/Multi-user+debugging+in+PhpStorm+with+Xdebug+and+DBGp+proxy#Multi-userdebugginginPhpStormwithXdebugandDBGpproxy-HowdoesXdebugwork%3F).
6 |
7 | The main benefit is that this proxy is written in php - the language you know.
8 |
9 | [](https://packagist.org/packages/mougrim/php-xdebug-proxy)
10 | [](https://packagist.org/packages/mougrim/php-xdebug-proxy)
11 | [](https://packagist.org/packages/mougrim/php-xdebug-proxy)
12 | [](https://github.com/mougrim/php-xdebug-proxy/actions?query=branch%3Amaster)
13 |
14 | ### Installation
15 |
16 | This package can be installed as a [Composer](https://getcomposer.org/) project:
17 |
18 | ```bash
19 | composer.phar create-project mougrim/php-xdebug-proxy
20 | ```
21 |
22 | Or dependency:
23 |
24 | ```bash
25 | composer.phar require mougrim/php-xdebug-proxy --dev
26 | ```
27 |
28 | For parse XML you should install `ext-dom`.
29 |
30 | For write logs by default you should install `amphp/log` (use `--dev` if you installed `php-xdebug-proxy` as dependency):
31 |
32 | ```bash
33 | composer.phar require amphp/log '^1.0.0'
34 | ```
35 |
36 |
37 | ### Run
38 |
39 | You can run next command:
40 | ```bash
41 | bin/xdebug-proxy
42 | ```
43 |
44 | The proxy will be run with default config:
45 | ```text
46 | Using config path /path/to/php-xdebug-proxy/config
47 | [2019-02-14 10:46:24] xdebug-proxy.NOTICE: Use default ide: 127.0.0.1:9000 array ( ) array ( )
48 | [2019-02-14 10:46:24] xdebug-proxy.NOTICE: Use predefined ides array ( 'predefinedIdeList' => array ( 'idekey' => '127.0.0.1:9000', ), ) array ( )
49 | [2019-02-14 10:46:24] xdebug-proxy.NOTICE: [Proxy][IdeRegistration] Listening for new connections on '127.0.0.1:9001'... array ( ) array ( )
50 | [2019-02-14 10:46:24] xdebug-proxy.NOTICE: [Proxy][Xdebug] Listening for new connections on '127.0.0.1:9002'... array ( ) array ( )
51 | ```
52 |
53 | So by default proxy listens `127.0.0.1:9001` for ide registration connections and `127.0.0.1:9002` for xdebug connections, use `127.0.0.1:9000` as default IDE and predefined IDE with key `idekey`.
54 |
55 | ### Config
56 |
57 | If you want to configure listening ports, etc., you can use custom config path. Just copy [`config`](config) directory to your custom path:
58 |
59 | ```bash
60 | cp -r /path/to/php-xdebug-proxy/config /your/custom/path
61 | ```
62 |
63 | There are 3 files:
64 |
65 | - [`config.php`](config/config.php):
66 | ```php
67 | [
70 | // xdebug proxy server host:port
71 | 'listen' => '127.0.0.1:9002',
72 | ],
73 | 'ideServer' => [
74 | // if proxy can't find ide, then it uses default ide,
75 | // pass empty string if you want to disable default ide
76 | // defaultIde is useful when there is only one user for proxy
77 | 'defaultIde' => '127.0.0.1:9000',
78 | // predefined ide list in format 'idekey' => 'host:port',
79 | // pass empty array if you don't need predefined ide list
80 | // predefinedIdeList is useful when proxy's users aren't changed often,
81 | // so they don't need to register in proxy each proxy restart
82 | 'predefinedIdeList' => [
83 | 'idekey' => '127.0.0.1:9000',
84 | ],
85 | ],
86 | 'ideRegistrationServer' => [
87 | // host:port for register ide in proxy
88 | // pass empty string if you want to disable ide registration
89 | 'listen' => '127.0.0.1:9001',
90 | ],
91 | ];
92 | ```
93 | - [`logger.php`](config/logger.php): you can customize a logger, the file should return an object, which is instance of `\Psr\Log\LoggerInterface`;
94 | - [`factory.php`](config/factory.php): you can [customize classes](#extending), which are used in proxy, file should return object, which is instanceof [`Factory\Factory`](src/Factory/Factory.php).
95 |
96 | Then change configs and run:
97 |
98 | ```bash
99 | bin/xdebug-proxy --configs=/your/custom/path/config
100 | ```
101 |
102 | ### Extending
103 |
104 | As mentioned [above](#factory-php) you can customize classes using your custom factory, which implements [`Factory\Factory`](src/Factory/Factory.php). By default [`Factory\DefaultFactory`](src/Factory/DefaultFactory.php) factory is used.
105 |
106 | The most powerful are the request preparers. You can override `Factory\DefaultFactory::createRequestPreparers()`. It should return an array of objects which implement [`RequestPreparer\RequestPreparer`](src/RequestPreparer/RequestPreparer.php) interface.
107 |
108 | Request preparers will be called:
109 | - on request to ide from first to last
110 | - on request to xdebug from last to first
111 |
112 | You can use request preparer for example for changing path to files (in break points and execution files).
113 |
114 | Good example of the request preparer is [`RequestPreparer\SoftMocksRequestPreparer`](src/RequestPreparer/SoftMocksRequestPreparer.php). You can see its usage in [`Factory\SoftMocksFactory`](src/Factory/SoftMocksFactory.php).
115 |
116 | ### Using with soft-mocks
117 |
118 | For soft-mocks you can use [`softMocksConfig`](softMocksConfig) config directory:
119 | ```bash
120 | bin/xdebug-proxy --configs=/path/to/php-xdebug-proxy/softMocksConfig
121 | ```
122 |
123 | If you you want to provide path to custom `soft-mocks` init script, then copy [`softMocksConfig`](softMocksConfig) and change [`config.php`](softMocksConfig/config.php):
124 | ```php
125 | ...
126 | 'softMocks' => [
127 | // if empty string, then vendor/badoo/soft-mocks/src/init_with_composer.php is used
128 | 'initScript' => '/your/custom/init-script.php',
129 | ],
130 | ...
131 | ```
132 |
133 | For more information see doc in [soft-mocks](https://github.com/badoo/soft-mocks/#using-with-xdebug) project.
134 |
135 | ### Thanks
136 |
137 | Many thanks to [Eelf](https://github.com/eelf) for proxy example [smdbgpproxy](https://github.com/eelf/smdbgpproxy).
138 |
139 | Thanks to [Dmitry Ananyev](https://github.com/altexdim) for help with docs.
140 |
--------------------------------------------------------------------------------
/UPGRADE.md:
--------------------------------------------------------------------------------
1 | ## PHP xdebug (dbgp) proxy upgrade instructions
2 |
3 | !!!IMPORTANT!!!
4 |
5 | The following upgrading instructions are cumulative. That is, if you want to upgrade from version A to version C and there is version B between A and C, you need to follow the instructions for both A and B.
6 |
7 | ### Upgrade from v0.3.0
8 | - new parameter `$config` was added to `\Mougrim\XdebugProxy\Factory\Factory::createRequestPreparers()`, if you implement `\Mougrim\XdebugProxy\Factory\Factory` interface, then implement this parameter too
9 | - now `\Mougrim\XdebugProxy\Factory\SoftMocksFactory::createConfig()` should return `\Mougrim\XdebugProxy\Config\SoftMocksConfig`, so if you redeclared this method, then return `\Mougrim\XdebugProxy\Config\SoftMocksConfig` or inheritor in it
10 |
11 | ### Upgrade from v0.2.1
12 | - use interface `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer` instead of `\Mougrim\XdebugProxy\RequestPreparer`
13 | - check your php version, now php 7.1 is minimum php version
14 | - check interface compatible for:
15 | - `\Mougrim\XdebugProxy\Handler\DefaultIdeHandler`
16 | - `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer`
17 | - `\Mougrim\XdebugProxy\Xml\XmlDocument`
18 | - `\Mougrim\XdebugProxy\Proxy`
19 | - `\Mougrim\XdebugProxy\Runner`
20 |
21 | ### Upgrade from v0.1.0
22 | - New parameter `$config` was added to `\Mougrim\XdebugProxy\Factory\Factory::createIdeHandler()`, if you implement `\Mougrim\XdebugProxy\Factory\Factory` interface, then implement this parameter too.
23 | - New parameter `$config` was added to `\Mougrim\XdebugProxy\Handler\DefaultIdeHandler`'s constructor, if you extends this class' constructor, then implement this parameter.
24 | - Now by default `defaultIde` is `'127.0.0.1:9000'` and `predefinedIdeList` is `'idekey' => '127.0.0.1:9000'`. Pass in `config.php` `ideServer` with `'defaultIde' => ''` and `'predefinedIdeList' => []` if you don't need default ide and predefined ide list. See [README.md](README.md#config) for more details about `config.php`.
25 |
26 | ### Upgrade from v0.0.1
27 |
28 | - class `\Mougrim\XdebugProxy\RequestPreparer` is deprecated and will be removed in next releases, use `\Mougrim\XdebugProxy\RequestPreparer\RequestPreparer` instead of it
29 | - `\Mougrim\XdebugProxy\Handler\CommandToXdebugParser::buildCommand()` method was added, implement this method if you implement it
30 |
--------------------------------------------------------------------------------
/bin/xdebug-proxy:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 |
7 | */
8 | namespace Mougrim\XdebugProxy;
9 |
10 | use function file_exists;
11 | use function fwrite;
12 | use const PHP_EOL;
13 | use const STDERR;
14 |
15 | $composerInstall = '';
16 | foreach ([__DIR__ . '/../../../autoload.php', __DIR__ . '/../vendor/autoload.php'] as $file) {
17 | if (file_exists($file)) {
18 | $composerInstall = $file;
19 |
20 | break;
21 | }
22 | }
23 | if (!$composerInstall) {
24 | fwrite(
25 | STDERR,
26 | 'You need to set up the project dependencies using Composer:' . PHP_EOL . PHP_EOL .
27 | ' composer install' . PHP_EOL . PHP_EOL .
28 | 'You can learn all about Composer on https://getcomposer.org/.' . PHP_EOL
29 | );
30 | exit(1);
31 | }
32 | require $composerInstall;
33 | unset($composerInstall);
34 |
35 | (new Runner())
36 | ->run();
37 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mougrim/php-xdebug-proxy",
3 | "description": "Xdebug (dbgp) proxy",
4 | "type": "project",
5 | "require": {
6 | "php": ">=8.1",
7 | "amphp/socket": "^2.3"
8 | },
9 | "require-dev": {
10 | "ext-dom": "*",
11 | "roave/security-advisories": "dev-latest",
12 | "phpro/grumphp": "^2.9",
13 | "friendsofphp/php-cs-fixer": "^3.64.0",
14 | "phpunit/phpunit": "^10.5",
15 | "amphp/log": "^2.0",
16 | "phpstan/phpstan": "^2.0",
17 | "jetbrains/phpstorm-attributes": "^1.2"
18 | },
19 | "license": "MIT",
20 | "authors": [
21 | {
22 | "name": "Mougrim",
23 | "email": "rinat@mougrim.ru"
24 | }
25 | ],
26 | "bin": ["bin/xdebug-proxy"],
27 | "autoload": {
28 | "psr-4": {
29 | "Mougrim\\XdebugProxy\\": "src"
30 | }
31 | },
32 | "autoload-dev": {
33 | "psr-4": {
34 | "Tests\\Mougrim\\XdebugProxy\\": "tests"
35 | }
36 | },
37 | "suggest": {
38 | "ext-dom": "Required for parse XML by default",
39 | "ext-pcntl": "Required for graceful stop daemon",
40 | "amphp/log": "Required for write logs by default"
41 | },
42 | "config": {
43 | "allow-plugins": {
44 | "phpro/grumphp": true
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/config/config.php:
--------------------------------------------------------------------------------
1 |
7 | */
8 |
9 | namespace Mougrim\XdebugProxy\config;
10 |
11 | return [
12 | 'xdebugServer' => [
13 | 'listen' => '127.0.0.1:9002',
14 | ],
15 | 'ideServer' => [
16 | 'defaultIde' => '127.0.0.1:9000',
17 | 'predefinedIdeList' => [
18 | 'idekey' => '127.0.0.1:9000',
19 | ],
20 | ],
21 | 'ideRegistrationServer' => [
22 | 'listen' => '127.0.0.1:9001',
23 | ],
24 | ];
25 |
--------------------------------------------------------------------------------
/config/factory.php:
--------------------------------------------------------------------------------
1 |
7 | */
8 |
9 | namespace Mougrim\XdebugProxy\config;
10 |
11 | use Mougrim\XdebugProxy\Factory\DefaultFactory;
12 |
13 | return new DefaultFactory();
14 |
--------------------------------------------------------------------------------
/config/logger.php:
--------------------------------------------------------------------------------
1 |
6 | */
7 |
8 | namespace Mougrim\XdebugProxy\config;
9 |
10 | use Amp\Log\StreamHandler;
11 | use Monolog\Logger;
12 | use Mougrim\XdebugProxy\LoggerFormatter;
13 | use Mougrim\XdebugProxy\RunError;
14 | use Psr\Log\LogLevel;
15 |
16 | use function Amp\ByteStream\getStdout;
17 | use function class_exists;
18 |
19 | if (!class_exists(StreamHandler::class)) {
20 | throw new RunError(
21 | 'You should install "amphp/log" by default or provide your custom config/logger.php via config for use php-xdebug-proxy'
22 | );
23 | }
24 |
25 | return (new Logger('xdebug-proxy'))
26 | ->pushHandler(
27 | (new StreamHandler(getStdout(), LogLevel::NOTICE))
28 | ->setFormatter(new LoggerFormatter())
29 | );
30 |
--------------------------------------------------------------------------------
/grumphp.yml:
--------------------------------------------------------------------------------
1 | grumphp:
2 | hooks_dir: ~
3 | hooks_preset: local
4 | stop_on_failure: false
5 | ignore_unstaged_changes: false
6 | process_timeout: 300
7 | tasks:
8 | phpcsfixer2:
9 | allow_risky: true
10 | config: .php-cs-fixer.php
11 | phpstan:
12 | autoload_file: ~
13 | configuration: phpstan.neon
14 | level: null
15 | force_patterns: []
16 | ignore_patterns: []
17 | triggered_by: ['php']
18 | memory_limit: "-1"
19 | use_grumphp_paths: true
20 | extensions: []
21 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | tmpDir: tmp
3 | level: 10
4 | paths:
5 | - bin
6 | - config
7 | - softMocksConfig
8 | - src
9 | - tests
10 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 | tests/Unit
19 |
20 |
21 |
22 |
23 | src
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/softMocksConfig/config.php:
--------------------------------------------------------------------------------
1 |
7 | */
8 |
9 | namespace Mougrim\XdebugProxy\softMocksConfig;
10 |
11 | return [
12 | 'xdebugServer' => [
13 | 'listen' => '127.0.0.1:9002',
14 | ],
15 | 'ideServer' => [
16 | 'defaultIde' => '127.0.0.1:9000',
17 | 'predefinedIdeList' => [
18 | 'idekey' => '127.0.0.1:9000',
19 | ],
20 | ],
21 | 'ideRegistrationServer' => [
22 | 'listen' => '127.0.0.1:9001',
23 | ],
24 | 'softMocks' => [
25 | 'initScript' => '',
26 | ],
27 | ];
28 |
--------------------------------------------------------------------------------
/softMocksConfig/factory.php:
--------------------------------------------------------------------------------
1 |
7 | */
8 |
9 | namespace Mougrim\XdebugProxy\softMocksConfig;
10 |
11 | use Mougrim\XdebugProxy\Factory\SoftMocksFactory;
12 |
13 | return new SoftMocksFactory();
14 |
--------------------------------------------------------------------------------
/softMocksConfig/logger.php:
--------------------------------------------------------------------------------
1 |
6 | */
7 |
8 | namespace Mougrim\XdebugProxy\softMocksConfig;
9 |
10 | use Amp\Log\StreamHandler;
11 | use Monolog\Logger;
12 | use Mougrim\XdebugProxy\LoggerFormatter;
13 | use Mougrim\XdebugProxy\RunError;
14 | use Psr\Log\LogLevel;
15 |
16 | use function Amp\ByteStream\getStdout;
17 | use function class_exists;
18 |
19 | if (!class_exists(StreamHandler::class)) {
20 | throw new RunError(
21 | 'You should install "amphp/log" by default or provide your custom config/logger.php via config for use php-xdebug-proxy'
22 | );
23 | }
24 |
25 | return (new Logger('xdebug-proxy'))
26 | ->pushHandler(
27 | (new StreamHandler(getStdout(), LogLevel::NOTICE))
28 | ->setFormatter(new LoggerFormatter())
29 | );
30 |
--------------------------------------------------------------------------------
/src/Config/Config.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * @phpstan-import-type ServerConfigArray from Server
11 | * @phpstan-import-type ServerDefaultConfigArray from Server
12 | * @phpstan-import-type IdeServerConfigArray from IdeServer
13 | * @phpstan-import-type IdeServerDefaultConfigArray from IdeServer
14 | *
15 | * @phpstan-type XdebugProxyConfigArray array{xdebugServer?: ServerConfigArray, ideRegistrationServer?: ServerConfigArray, ideServer?: IdeServerConfigArray}
16 | */
17 | class Config
18 | {
19 | /** @var ServerDefaultConfigArray */
20 | public const DEFAULT_XDEBUG_SERVER_CONFIG = [
21 | 'listen' => '127.0.0.1:9002',
22 | ];
23 |
24 | /** @var ServerDefaultConfigArray */
25 | public const DEFAULT_IDE_REGISTRATION_SERVER_CONFIG = [
26 | 'listen' => '127.0.0.1:9001',
27 | ];
28 |
29 | /** @var IdeServerDefaultConfigArray */
30 | public const DEFAULT_IDE_SERVER_CONFIG = [
31 | 'defaultIde' => '127.0.0.1:9000',
32 | 'predefinedIdeList' => [
33 | 'idekey' => '127.0.0.1:9000',
34 | ],
35 | ];
36 |
37 | protected readonly Server $xdebugServer;
38 | protected readonly Server $ideRegistrationServer;
39 | protected readonly IdeServer $ideServer;
40 |
41 | /**
42 | * @param XdebugProxyConfigArray $config
43 | */
44 | public function __construct(array $config)
45 | {
46 | $this->xdebugServer = new Server(
47 | $config['xdebugServer'] ?? [],
48 | static::DEFAULT_XDEBUG_SERVER_CONFIG
49 | );
50 | $this->ideRegistrationServer = new Server(
51 | $config['ideRegistrationServer'] ?? [],
52 | static::DEFAULT_IDE_REGISTRATION_SERVER_CONFIG
53 | );
54 | $this->ideServer = new IdeServer(
55 | $config['ideServer'] ?? [],
56 | static::DEFAULT_IDE_SERVER_CONFIG
57 | );
58 | }
59 |
60 | public function getXdebugServer(): Server
61 | {
62 | return $this->xdebugServer;
63 | }
64 |
65 | public function getIdeRegistrationServer(): Server
66 | {
67 | return $this->ideRegistrationServer;
68 | }
69 |
70 | public function getIdeServer(): IdeServer
71 | {
72 | return $this->ideServer;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/Config/IdeServer.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * @phpstan-type IdeServerConfigArray array{defaultIde?: string, predefinedIdeList?: array}
11 | * @phpstan-type IdeServerDefaultConfigArray array{defaultIde: string, predefinedIdeList: array}
12 | */
13 | class IdeServer
14 | {
15 | /**
16 | * @param IdeServerConfigArray $config
17 | * @param IdeServerDefaultConfigArray $defaultConfig
18 | */
19 | public function __construct(
20 | protected readonly array $config,
21 | protected readonly array $defaultConfig,
22 | ) {
23 | }
24 |
25 | public function getDefaultIde(): string
26 | {
27 | return $this->config['defaultIde'] ?? $this->defaultConfig['defaultIde'];
28 | }
29 |
30 | /**
31 | * @return array
32 | */
33 | public function getPredefinedIdeList(): array
34 | {
35 | return $this->config['predefinedIdeList'] ?? $this->defaultConfig['predefinedIdeList'];
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Config/Server.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * @phpstan-type ServerConfigArray array{listen?: string}
11 | * @phpstan-type ServerDefaultConfigArray array{listen: string}
12 | */
13 | class Server
14 | {
15 | /**
16 | * @param ServerConfigArray $config
17 | * @param ServerDefaultConfigArray $defaultConfig
18 | */
19 | public function __construct(
20 | protected readonly array $config,
21 | protected readonly array $defaultConfig,
22 | ) {
23 | }
24 |
25 | public function getListen(): string
26 | {
27 | return $this->config['listen'] ?? $this->defaultConfig['listen'];
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Config/SoftMocks.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * @phpstan-type SoftMocksConfigArray array{initScript?: string}
11 | * @phpstan-type SoftMocksDefaultConfigArray array{initScript: string}
12 | */
13 | class SoftMocks
14 | {
15 | /**
16 | * @param SoftMocksConfigArray $config
17 | * @param SoftMocksDefaultConfigArray $defaultConfig
18 | */
19 | public function __construct(
20 | protected readonly array $config,
21 | protected readonly array $defaultConfig,
22 | ) {
23 | }
24 |
25 | public function getInitScript(): string
26 | {
27 | return $this->config['initScript'] ?? $this->defaultConfig['initScript'];
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/Config/SoftMocksConfig.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * @phpstan-import-type ServerConfigArray from Server
11 | * @phpstan-import-type IdeServerConfigArray from IdeServer
12 | * @phpstan-import-type SoftMocksConfigArray from SoftMocks
13 | * @phpstan-import-type SoftMocksDefaultConfigArray from SoftMocks
14 | *
15 | * @phpstan-type XdebugProxySoftMocksConfigArray array{xdebugServer?: ServerConfigArray, ideRegistrationServer?: ServerConfigArray, ideServer?: IdeServerConfigArray, softMocks?: SoftMocksConfigArray}
16 | */
17 | class SoftMocksConfig extends Config
18 | {
19 | /** @var SoftMocksDefaultConfigArray */
20 | public const DEFAULT_SOFT_MOCKS_CONFIG = [
21 | 'initScript' => '',
22 | ];
23 |
24 | protected readonly SoftMocks $softMocks;
25 |
26 | /**
27 | * @param XdebugProxySoftMocksConfigArray $config
28 | */
29 | public function __construct(array $config)
30 | {
31 | parent::__construct($config);
32 | $this->softMocks = new SoftMocks(
33 | $config['softMocks'] ?? [],
34 | static::DEFAULT_SOFT_MOCKS_CONFIG
35 | );
36 | }
37 |
38 | public function getSoftMocks(): SoftMocks
39 | {
40 | return $this->softMocks;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/Enum/RegistrationCommand.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | enum RegistrationCommand: string
11 | {
12 | case Init = 'proxyinit';
13 | case Stop = 'proxystop';
14 | }
15 |
--------------------------------------------------------------------------------
/src/Enum/RegistrationError.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | enum RegistrationError: int
11 | {
12 | case UnknownCommand = 1;
13 | case ArgumentFormat = 2;
14 | case MissingRequiredArguments = 3;
15 | }
16 |
--------------------------------------------------------------------------------
/src/Factory/DefaultFactory.php:
--------------------------------------------------------------------------------
1 |
21 | *
22 | * @phpstan-import-type XdebugProxyConfigArray from Config
23 | */
24 | class DefaultFactory implements Factory
25 | {
26 | /**
27 | * @param array> $config
28 | * @phpstan-param XdebugProxyConfigArray $config
29 | */
30 | public function createConfig(array $config): Config
31 | {
32 | return new Config($config);
33 | }
34 |
35 | public function createXmlConverter(LoggerInterface $logger): XmlConverter
36 | {
37 | return new DomXmlConverter($logger);
38 | }
39 |
40 | /**
41 | * @param RequestPreparer[] $requestPreparers
42 | */
43 | public function createIdeHandler(
44 | LoggerInterface $logger,
45 | IdeServerConfig $config,
46 | XmlConverter $xmlConverter,
47 | array $requestPreparers,
48 | ): IdeHandler {
49 | return new DefaultIdeHandler($logger, $config, $xmlConverter, $requestPreparers);
50 | }
51 |
52 | public function createRequestPreparers(LoggerInterface $logger, Config $config): array
53 | {
54 | return [];
55 | }
56 |
57 | public function createXdebugHandler(
58 | LoggerInterface $logger,
59 | XmlConverter $xmlConverter,
60 | IdeHandler $ideHandler,
61 | ): XdebugHandler {
62 | return new DefaultXdebugHandler($logger, $xmlConverter, $ideHandler);
63 | }
64 |
65 | public function createProxy(
66 | LoggerInterface $logger,
67 | Config $config,
68 | XmlConverter $xmlConverter,
69 | IdeHandler $ideHandler,
70 | XdebugHandler $xdebugHandler,
71 | ): Proxy {
72 | return new Proxy($logger, $config, $xmlConverter, $ideHandler, $xdebugHandler);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/Factory/Factory.php:
--------------------------------------------------------------------------------
1 |
20 | *
21 | * @phpstan-import-type XdebugProxyConfigArray from Config
22 | */
23 | interface Factory
24 | {
25 | /**
26 | * @param array> $config
27 | * @phpstan-param XdebugProxyConfigArray $config
28 | */
29 | public function createConfig(array $config): Config;
30 |
31 | public function createXmlConverter(LoggerInterface $logger): XmlConverter;
32 |
33 | /**
34 | * @param RequestPreparer[] $requestPreparers
35 | */
36 | public function createIdeHandler(
37 | LoggerInterface $logger,
38 | IdeServerConfig $config,
39 | XmlConverter $xmlConverter,
40 | array $requestPreparers,
41 | ): IdeHandler;
42 |
43 | /**
44 | * Request preparers will be called:
45 | * - on request to ide from first to last;
46 | * - on request to xdebug from last to first.
47 | *
48 | * @return RequestPreparer[]
49 | *
50 | * @throws RequestPreparerException
51 | * @throws RequestPreparerError
52 | */
53 | public function createRequestPreparers(LoggerInterface $logger, Config $config): array;
54 |
55 | public function createXdebugHandler(
56 | LoggerInterface $logger,
57 | XmlConverter $xmlConverter,
58 | IdeHandler $ideHandler,
59 | ): XdebugHandler;
60 |
61 | public function createProxy(
62 | LoggerInterface $logger,
63 | Config $config,
64 | XmlConverter $xmlConverter,
65 | IdeHandler $ideHandler,
66 | XdebugHandler $xdebugHandler,
67 | ): Proxy;
68 | }
69 |
--------------------------------------------------------------------------------
/src/Factory/SoftMocksFactory.php:
--------------------------------------------------------------------------------
1 |
17 | *
18 | * @phpstan-import-type XdebugProxySoftMocksConfigArray from SoftMocksConfig
19 | */
20 | class SoftMocksFactory extends DefaultFactory
21 | {
22 | /**
23 | * @noinspection PhpMissingParentCallCommonInspection
24 | *
25 | * @param XdebugProxySoftMocksConfigArray $config
26 | * @return SoftMocksConfig
27 | */
28 | public function createConfig(array $config): Config
29 | {
30 | return new SoftMocksConfig($config);
31 | }
32 |
33 | /**
34 | * @param SoftMocksConfig $config
35 | * @return RequestPreparer[]
36 | *
37 | * @throws RequestPreparerException
38 | * @throws RequestPreparerError
39 | */
40 | public function createRequestPreparers(LoggerInterface $logger, Config $config): array
41 | {
42 | $requestPreparers = parent::createRequestPreparers($logger, $config);
43 | $requestPreparers[] = $this->createSoftMocksRequestPreparer($logger, $config);
44 |
45 | return $requestPreparers;
46 | }
47 |
48 | /**
49 | * @throws RequestPreparerError
50 | */
51 | public function createSoftMocksRequestPreparer(
52 | LoggerInterface $logger,
53 | SoftMocksConfig $config,
54 | ): SoftMocksRequestPreparer {
55 | return new SoftMocksRequestPreparer($logger, $config->getSoftMocks()->getInitScript());
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/Handler/CommandToXdebugParser.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | interface CommandToXdebugParser
11 | {
12 | /**
13 | * @return array{0: string, 1: array} [$command, $arguments]
14 | */
15 | public function parseCommand(string $request): array;
16 |
17 | /**
18 | * @param array $arguments
19 | */
20 | public function buildCommand(string $command, array $arguments): string;
21 | }
22 |
--------------------------------------------------------------------------------
/src/Handler/DefaultIdeHandler.php:
--------------------------------------------------------------------------------
1 |
40 | */
41 | class DefaultIdeHandler implements IdeHandler, CommandToXdebugParser
42 | {
43 | protected readonly string $defaultIde;
44 | /** @var array */
45 | protected array $ideList;
46 | /**
47 | * @var SplObjectStorage
48 | */
49 | protected readonly SplObjectStorage $ideSockets;
50 | protected int $maxIdeSockets = 100;
51 |
52 | /**
53 | * @param RequestPreparer[] $requestPreparers
54 | */
55 | public function __construct(
56 | protected readonly LoggerInterface $logger,
57 | protected readonly IdeServerConfig $config,
58 | protected readonly XmlConverter $xmlConverter,
59 | protected readonly array $requestPreparers,
60 | ) {
61 | $this->ideSockets = new SplObjectStorage();
62 | $this->defaultIde = $config->getDefaultIde();
63 | if ($this->defaultIde) {
64 | $this->logger->notice("Use default ide: {$this->defaultIde}");
65 | }
66 | $this->ideList = $config->getPredefinedIdeList();
67 | if ($this->ideList) {
68 | $this->logger->notice('Use predefined ides', ['predefinedIdeList' => $this->ideList]);
69 | }
70 | }
71 |
72 | public function getMaxIdeSockets(): int
73 | {
74 | return $this->maxIdeSockets;
75 | }
76 |
77 | public function setMaxIdeSockets(int $maxIdeSockets): static
78 | {
79 | $this->maxIdeSockets = $maxIdeSockets;
80 |
81 | return $this;
82 | }
83 |
84 | public function getIdeList(): array
85 | {
86 | return $this->ideList;
87 | }
88 |
89 | public function handle(ResourceSocket $socket): void
90 | {
91 | [$ip, $port] = explode(':', $socket->getRemoteAddress()->toString());
92 | $baseContext = [
93 | 'ide' => "{$ip}:{$port}",
94 | ];
95 | $this->logger->notice('[IdeRegistration] Accepted connection.', $baseContext);
96 |
97 | $request = '';
98 | while (($data = $socket->read()) !== null) {
99 | $request .= $data;
100 | if (str_contains($request, "\0")) {
101 | break;
102 | }
103 | }
104 | $requests = explode("\0", $request);
105 | if ($request && $request[strlen($request) - 1] !== "\0") {
106 | $this->logger->warning(
107 | "[IdeRegistration] Part of request isn't full, skip it",
108 | $baseContext + ['request' => $request]
109 | );
110 | $requests = array_slice($requests, 0, -1);
111 | }
112 | $responses = [];
113 | foreach ($requests as $request) {
114 | if (!$request) {
115 | continue;
116 | }
117 | $context = $baseContext;
118 | $context['request'] = $request;
119 | if (!str_contains($request, ' ')) {
120 | $this->logger->error('[IdeRegistration] Invalid request from IDE.', $context);
121 | continue;
122 | }
123 | $this->logger->notice('[IdeRegistration] Process request from IDE.', $context);
124 |
125 | [$command, $arguments] = $this->parseCommand($request);
126 | $context['command'] = $command;
127 |
128 | try {
129 | if (isset(static::REGISTRATION_ARGUMENTS[$command])) {
130 | $unsupportedArguments = array_diff(
131 | array_keys($arguments),
132 | static::REGISTRATION_ARGUMENTS[$command]['supportedArguments']
133 | );
134 | if ($unsupportedArguments) {
135 | $this->logger->warning(
136 | '[IdeRegistration] Skip unsupported arguments.',
137 | $context + ['unsupportedArguments' => $unsupportedArguments]
138 | );
139 | }
140 | $missingRequiredArguments = array_diff(
141 | static::REGISTRATION_ARGUMENTS[$command]['requiredArguments'],
142 | array_keys($arguments)
143 | );
144 | if ($missingRequiredArguments) {
145 | $this->logger->error(
146 | '[IdeRegistration] Missing required arguments.',
147 | $context + ['missingRequiredArguments' => $missingRequiredArguments]
148 | );
149 | throw new IdeRegistrationException(
150 | RegistrationError::MissingRequiredArguments,
151 | 'Next required arguments are missing: '
152 | . implode(', ', $missingRequiredArguments),
153 | $command
154 | );
155 | }
156 | }
157 |
158 | switch ($command) {
159 | case 'proxyinit':
160 | $context['key'] = $arguments['-k'];
161 | if (!preg_match('/^[1-9]\d*$/', $arguments['-p'])) {
162 | $this->logger->error(
163 | '[IdeRegistration] Port should be a number.',
164 | $context + ['port' => $arguments['-p']]
165 | );
166 | throw new IdeRegistrationException(
167 | RegistrationError::ArgumentFormat,
168 | 'Port should be a number',
169 | $command
170 | );
171 | }
172 | $newIde = "{$ip}:{$arguments['-p']}";
173 | $context['ide'] = $newIde;
174 | if (isset($this->ideList[$arguments['-k']])) {
175 | $this->logger->notice(
176 | "[IdeRegistration] Change ide from '{$this->ideList[$arguments['-k']]}' to '{$newIde}'",
177 | $context
178 | );
179 | } else {
180 | $this->logger->notice(
181 | "[IdeRegistration] Add new ide '{$newIde}'",
182 | $context
183 | );
184 | }
185 | $this->ideList[$arguments['-k']] = $newIde;
186 | $xmlContainer = (new XmlContainer('proxyinit'))
187 | ->addAttribute('success', '1')
188 | ->addAttribute('idekey', $arguments['-k'])
189 | ->addAttribute('address', $ip)
190 | ->addAttribute('port', $arguments['-p']);
191 | break;
192 | case 'proxystop':
193 | if (isset($this->ideList[$arguments['-k']])) {
194 | $this->logger->notice(
195 | "[IdeRegistration] Remove ide key '{$arguments['-k']}' in '{$this->ideList[$arguments['-k']]}'",
196 | $context
197 | );
198 | unset($this->ideList[$arguments['-k']]);
199 | } else {
200 | $this->logger->notice(
201 | "[IdeRegistration] Ide key '{$arguments['-k']}' isn't used",
202 | $context
203 | );
204 | }
205 | $xmlContainer = (new XmlContainer('proxystop'))
206 | ->addAttribute('success', '1')
207 | ->addAttribute('idekey', $arguments['-k']);
208 | break;
209 | default:
210 | $this->logger->error('[IdeRegistration] Unknown command from IDE.', $context);
211 | throw new IdeRegistrationException(
212 | RegistrationError::UnknownCommand,
213 | "Unknown command '{$command}'"
214 | );
215 | }
216 | } catch (IdeRegistrationException $exception) {
217 | $xmlContainerMessage = (new XmlContainer('message'))
218 | ->setContent($exception->getMessage());
219 | $xmlContainerError = (new XmlContainer('error'))
220 | ->addAttribute('id', (string) $exception->getError()->value)
221 | ->addChild($xmlContainerMessage);
222 | $xmlContainer = (new XmlContainer($exception->getCommand()))
223 | ->addAttribute('success', '0')
224 | ->addChild($xmlContainerError);
225 | }
226 | $xmlDocument = new XmlDocument('1.0', 'UTF-8', $xmlContainer);
227 | try {
228 | $responses[] = $this->xmlConverter->generate($xmlDocument);
229 | } catch (XmlException $exception) {
230 | $this->logger->notice("[IdeRegistration] Can't generate response: {$exception}", $context);
231 | try {
232 | $socket->end();
233 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException|StreamException) {
234 | // we can't do anything else after try to close connection
235 | }
236 |
237 | return;
238 | }
239 | }
240 | $response = '';
241 | if ($responses) {
242 | $response = implode("\0", $responses);
243 | }
244 | $this->logger->notice(
245 | '[IdeRegistration] Send response.',
246 | $baseContext + ['response' => $response]
247 | );
248 | try {
249 | $socket->write($response);
250 | $socket->end();
251 | } catch (ClosedException|StreamException $exception) {
252 | $this->logger->error(
253 | "[IdeRegistration] Can't write response to ide: {$exception}",
254 | $baseContext + ['response' => $response]
255 | );
256 | }
257 | }
258 |
259 | /**
260 | * @throws FromXdebugProcessError
261 | * @throws FromXdebugProcessException
262 | */
263 | public function processRequest(XmlDocument $xmlRequest, string $rawRequest, ResourceSocket $xdebugSocket): void
264 | {
265 | $context = [
266 | 'xdebug' => $xdebugSocket->getRemoteAddress()->toString(),
267 | 'request' => $rawRequest,
268 | ];
269 | if (!$this->ideSockets->contains($xdebugSocket)) {
270 | $this->processInit($xmlRequest, $rawRequest, $xdebugSocket);
271 | }
272 | $ideSocket = $this->ideSockets->offsetGet($xdebugSocket);
273 | $context['ide'] = $ideSocket->getRemoteAddress()->toString();
274 | try {
275 | $this->prepareRequestToIde($xmlRequest, $rawRequest, $context);
276 | } catch (RequestPreparerError $error) {
277 | throw new FromXdebugProcessError("Can't prepare request to ide", $context, $error);
278 | }
279 |
280 | try {
281 | $request = $this->xmlConverter->generate($xmlRequest);
282 | } catch (XmlException $exception) {
283 | throw new FromXdebugProcessError("Can't generate response", $context, $exception);
284 | }
285 | try {
286 | $ideSocket->write(strlen($request) . "\0{$request}\0");
287 | } catch (ClosedException|StreamException $exception) {
288 | throw new FromXdebugProcessError(
289 | "Can't send request to ide",
290 | $context + ['generatedRequest' => $request],
291 | $exception
292 | );
293 | }
294 |
295 | $this->logger->debug('[Xdebug][Ide] Request was sent to ide, waiting response.', $context);
296 | }
297 |
298 | /**
299 | * @param array $context
300 | *
301 | * @throws RequestPreparerError
302 | */
303 | protected function prepareRequestToIde(XmlDocument $xmlRequest, string $rawRequest, array $context): void
304 | {
305 | foreach ($this->requestPreparers as $requestPreparer) {
306 | try {
307 | $requestPreparer->prepareRequestToIde($xmlRequest, $rawRequest);
308 | } catch (RequestPreparerException $exception) {
309 | $this->logger->error(
310 | "Can't prepare request to ide: {$exception}",
311 | $context + [
312 | 'preparer' => get_class($requestPreparer),
313 | ]
314 | );
315 | }
316 | }
317 | }
318 |
319 | public function close(ResourceSocket $xdebugSocket): void
320 | {
321 | if (!$this->ideSockets->contains($xdebugSocket)) {
322 | return;
323 | }
324 | $ideSocket = $this->ideSockets->offsetGet($xdebugSocket);
325 |
326 | try {
327 | $ideSocket->end();
328 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException) {
329 | // already closed
330 | } catch (StreamException $exception) {
331 | $this->logger->error(
332 | "Can't close ide socket: {$exception}",
333 | ['exception' => $exception],
334 | );
335 | }
336 | $this->ideSockets->detach($xdebugSocket);
337 | }
338 |
339 | /**
340 | * @throws FromXdebugProcessError
341 | * @throws FromXdebugProcessException
342 | */
343 | protected function processInit(XmlDocument $xmlRequest, string $rawRequest, ResourceSocket $xdebugSocket): void
344 | {
345 | $context = [
346 | 'xdebug' => $xdebugSocket->getRemoteAddress()->toString(),
347 | 'request' => $rawRequest,
348 | ];
349 | $xmlContainer = $xmlRequest->getRoot();
350 | if (!$xmlContainer) {
351 | throw new FromXdebugProcessError("Can't get document root", $context);
352 | }
353 |
354 | if ($xmlContainer->getName() !== 'init') {
355 | throw new FromXdebugProcessError("First request's root should be init", $context);
356 | }
357 |
358 | $ideKey = $xmlContainer->getAttributes()['idekey'] ?? null;
359 | if (!$ideKey) {
360 | throw new FromXdebugProcessError('Ide key is empty', $context);
361 | }
362 | $context['ideKey'] = $ideKey;
363 | $ide = $this->ideList[$ideKey] ?? $this->defaultIde;
364 | if (!$ide) {
365 | throw new FromXdebugProcessException('No any ide', $context);
366 | }
367 | $context['ide'] = $ide;
368 |
369 | if ($this->ideSockets->count() >= $this->maxIdeSockets) {
370 | throw new FromXdebugProcessException('Max connections exceeded', $context);
371 | }
372 |
373 | $this->logger->notice('[Xdebug][Ide][Init] Try to init connect to ide.', $context);
374 |
375 | try {
376 | $ideSocket = connect("tcp://{$ide}");
377 | } catch (ConnectException|CancelledException $exception) {
378 | throw new FromXdebugProcessError("Can't connect to ide", $context, $exception);
379 | }
380 | $this->ideSockets->attach($xdebugSocket, $ideSocket);
381 |
382 | $this->logger->notice('[Xdebug][Ide][Init] Successful connected to ide.', $context);
383 |
384 | async(fn () => $this->handleIde($ideKey, $xdebugSocket, $ideSocket));
385 | }
386 |
387 | protected function handleIde(string $ideKey, ResourceSocket $xdebugSocket, Socket $ideSocket): void
388 | {
389 | $context = [
390 | 'ide' => $ideSocket->getRemoteAddress()->toString(),
391 | 'key' => $ideKey,
392 | 'xdebug' => $xdebugSocket->getRemoteAddress()->toString(),
393 | ];
394 | $buffer = '';
395 | try {
396 | while (($chunk = $ideSocket->read()) !== null) {
397 | $buffer .= $chunk;
398 | while (str_contains($buffer, "\0")) {
399 | [$request, $buffer] = explode("\0", $buffer, 2);
400 | $this->logger->info(
401 | '[Xdebug][Ide] Process ide request',
402 | $context + ['request' => $request]
403 | );
404 | $request = $this->prepareRequestToXdebug($request, $context);
405 | $this->logger->debug(
406 | '[Xdebug][Ide] Send prepared request to xdebug',
407 | $context + ['request' => $request]
408 | );
409 | $xdebugSocket->write($request . "\0");
410 | }
411 | }
412 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException $exception) {
413 | // skip exception, close other connections below
414 | } catch (StreamException|RequestPreparerError $error) {
415 | $this->logger->critical(
416 | "Can't prepare request",
417 | $context + [
418 | 'exception' => $error,
419 | ],
420 | );
421 | // close other connections below
422 | }
423 |
424 | if ($buffer) {
425 | $this->logger->error(
426 | "[Xdebug][Ide] Buffer isn't empty after end handle from ide",
427 | $context + ['buffer' => $buffer]
428 | );
429 | }
430 |
431 | $this->logger->notice('[Xdebug][Ide] End handle from ide, close connections.', $context);
432 | $this->close($xdebugSocket);
433 | try {
434 | $xdebugSocket->end();
435 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException) {
436 | // already closed
437 | } catch (StreamException $exception) {
438 | $this->logger->error(
439 | "Can't close xdebug socket",
440 | $context + ['exception' => $exception]
441 | );
442 | }
443 | }
444 |
445 | /**
446 | * @param array $context
447 | * @return string prepared request
448 | *
449 | * @throws RequestPreparerError
450 | */
451 | protected function prepareRequestToXdebug(string $request, array $context): string
452 | {
453 | foreach (array_reverse($this->requestPreparers) as $requestPreparer) {
454 | try {
455 | $request = $requestPreparer->prepareRequestToXdebug($request, $this);
456 | } catch (RequestPreparerException $exception) {
457 | $this->logger->error(
458 | "Can't prepare request to xdebug: {$exception}",
459 | $context + [
460 | 'preparer' => get_class($requestPreparer),
461 | 'request' => $request,
462 | ]
463 | );
464 | }
465 | }
466 |
467 | return $request;
468 | }
469 |
470 | /**
471 | * @return array{string, array}
472 | */
473 | public function parseCommand(string $request): array
474 | {
475 | [$command, $arguments] = explode(' ', $request, 2);
476 | $arguments = $this->parseArguments($arguments);
477 |
478 | return [$command, $arguments];
479 | }
480 |
481 | /**
482 | * @param array $arguments
483 | */
484 | public function buildCommand(string $command, array $arguments): string
485 | {
486 | $argumentStrings = [];
487 | foreach ($arguments as $argument => $value) {
488 | $argumentStrings[] = "{$argument} {$value}";
489 | }
490 |
491 | return $command . ' ' . implode(' ', $argumentStrings);
492 | }
493 |
494 | /**
495 | * @return array
496 | */
497 | protected function parseArguments(string $arguments): array
498 | {
499 | $context = [
500 | 'arguments' => $arguments,
501 | ];
502 | $parts = explode(' ', $arguments);
503 | $partsQty = count($parts);
504 | /** @var array $result */
505 | $result = [];
506 | /** @noinspection ForeachInvariantsInspection */
507 | for ($i = 0; $i < $partsQty; $i++) {
508 | $part = $parts[$i];
509 | if (!$part) {
510 | continue;
511 | }
512 | if ($part[0] !== '-') {
513 | $this->logger->error("Can't parse argument {$part}", $context);
514 | continue;
515 | }
516 | if (!isset($parts[$i + 1])) {
517 | $this->logger->error("Can't get value argument {$part}", $context);
518 | break;
519 | }
520 |
521 | $i++;
522 | $result[$part] = $parts[$i];
523 | }
524 |
525 | return $result;
526 | }
527 | }
528 |
--------------------------------------------------------------------------------
/src/Handler/DefaultXdebugHandler.php:
--------------------------------------------------------------------------------
1 |
21 | */
22 | class DefaultXdebugHandler implements XdebugHandler
23 | {
24 | /**
25 | * @var SplObjectStorage
26 | */
27 | protected SplObjectStorage $requestBuffers;
28 |
29 | public function __construct(
30 | protected LoggerInterface $logger,
31 | protected XmlConverter $xmlConverter,
32 | protected IdeHandler $ideHandler,
33 | ) {
34 | $this->requestBuffers = new SplObjectStorage();
35 | }
36 |
37 | public function handle(ResourceSocket $socket): void
38 | {
39 | $baseContext = [
40 | 'xdebug' => $socket->getRemoteAddress()->toString(),
41 | ];
42 | $this->logger->notice('[Xdebug] Accepted connection', $baseContext);
43 |
44 | if (!$this->requestBuffers->contains($socket)) {
45 | $this->requestBuffers->attach($socket, '');
46 | }
47 | while (($data = $socket->read()) !== null) {
48 | $buffer = $this->requestBuffers->offsetGet($socket);
49 | $buffer .= $data;
50 | while (substr_count($buffer, "\0") >= 2) {
51 | $exception = null;
52 | try {
53 | [$length, $request, $buffer] = explode("\0", $buffer, 3);
54 | $context = $baseContext + ['request' => $request, 'requestLength' => $length];
55 | $this->logger->info('[Xdebug] Process request', $context);
56 |
57 | $requestLength = mb_strlen($request, '8bit');
58 | if ((string) $requestLength !== $length) {
59 | throw new FromXdebugProcessError(
60 | 'Wrong request length',
61 | $context + ['actualRequestLength' => $length]
62 | );
63 | }
64 |
65 | try {
66 | $xmlRequest = $this->xmlConverter->parse($request);
67 | } catch (XmlException $exception) {
68 | throw new FromXdebugProcessError("Can't parse request", $context, $exception);
69 | }
70 | $this->ideHandler->processRequest($xmlRequest, $request, $socket);
71 | } catch (FromXdebugProcessError $exception) {
72 | $message = "[Xdebug] {$exception->getMessage()}";
73 | if ($exception->getPrevious()) {
74 | $message .= ": {$exception->getPrevious()}";
75 | }
76 | $this->logger->error($message, $exception->getContext());
77 | } catch (FromXdebugProcessException $exception) {
78 | $message = "[Xdebug] {$exception->getMessage()}";
79 | if ($exception->getPrevious()) {
80 | $message .= ": {$exception->getPrevious()}";
81 | }
82 | $this->logger->notice($message, $exception->getContext());
83 | }
84 | if ($exception) {
85 | $this->ideHandler->close($socket);
86 | $this->requestBuffers->detach($socket);
87 | try {
88 | $socket->end();
89 | } /** @noinspection BadExceptionsProcessingInspection */ catch (ClosedException) {
90 | // already closed
91 | } catch (StreamException $exception) {
92 | $this->logger->error("Can't close socket", $context + ['exception' => $exception]);
93 | }
94 |
95 | return;
96 | }
97 | }
98 | $this->requestBuffers->attach($socket, $buffer);
99 | }
100 | $buffer = $this->requestBuffers->offsetGet($socket);
101 | if ($buffer) {
102 | $this->logger->warning("[Xdebug] Part of data wasn't parsed", $baseContext + ['buffer']);
103 | }
104 | $this->requestBuffers->detach($socket);
105 | $this->ideHandler->close($socket);
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/Handler/Exception.php:
--------------------------------------------------------------------------------
1 |
12 | */
13 | class Exception extends BaseException
14 | {
15 | /**
16 | * @param array $context
17 | */
18 | public function __construct(
19 | string $message,
20 | protected array $context = [],
21 | ?Throwable $previous = null,
22 | ) {
23 | parent::__construct($message, 0, $previous);
24 | }
25 |
26 | /**
27 | * @return array
28 | */
29 | public function getContext(): array
30 | {
31 | return $this->context;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Handler/FromXdebugProcessError.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | class FromXdebugProcessError extends Exception
11 | {
12 | }
13 |
--------------------------------------------------------------------------------
/src/Handler/FromXdebugProcessException.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | class FromXdebugProcessException extends Exception
11 | {
12 | }
13 |
--------------------------------------------------------------------------------
/src/Handler/Handler.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | interface Handler
13 | {
14 | public function handle(ResourceSocket $socket): void;
15 | }
16 |
--------------------------------------------------------------------------------
/src/Handler/IdeHandler.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | interface IdeHandler extends Handler
15 | {
16 | /** @var array, requiredArguments: array}> */
17 | public const REGISTRATION_ARGUMENTS = [
18 | /** @uses RegistrationCommand::Init */
19 | 'proxyinit' => [
20 | 'supportedArguments' => ['-p', '-k'],
21 | 'requiredArguments' => ['-p', '-k'],
22 | ],
23 | /** @uses RegistrationCommand::Stop */
24 | 'proxystop' => [
25 | 'supportedArguments' => ['-k'],
26 | 'requiredArguments' => ['-k'],
27 | ],
28 | ];
29 |
30 | /**
31 | * @return array
32 | */
33 | public function getIdeList(): array;
34 |
35 | /**
36 | * @throws FromXdebugProcessError
37 | * @throws FromXdebugProcessException
38 | */
39 | public function processRequest(XmlDocument $xmlRequest, string $rawRequest, ResourceSocket $xdebugSocket): void;
40 |
41 | public function close(ResourceSocket $xdebugSocket): void;
42 | }
43 |
--------------------------------------------------------------------------------
/src/Handler/IdeRegistrationException.php:
--------------------------------------------------------------------------------
1 |
12 | */
13 | class IdeRegistrationException extends Exception
14 | {
15 | public function __construct(
16 | protected RegistrationError $error,
17 | string $message,
18 | protected string $command = 'proxyerror',
19 | ) {
20 | parent::__construct($message, $error->value);
21 | }
22 |
23 | public function getError(): RegistrationError
24 | {
25 | return $this->error;
26 | }
27 |
28 | public function getCommand(): string
29 | {
30 | return $this->command;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/Handler/XdebugHandler.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | interface XdebugHandler extends Handler
11 | {
12 | }
13 |
--------------------------------------------------------------------------------
/src/LoggerFormatter.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | class LoggerFormatter extends LineFormatter
16 | {
17 | /** @noinspection PhpMissingParentCallCommonInspection */
18 | protected function convertToString($data): string
19 | {
20 | if (is_scalar($data)) {
21 | return (string) $data;
22 | }
23 |
24 | return var_export($data, true);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Proxy.php:
--------------------------------------------------------------------------------
1 |
26 | */
27 | class Proxy
28 | {
29 | /** @var ResourceServerSocket[] */
30 | protected array $servers = [];
31 |
32 | public function __construct(
33 | protected readonly LoggerInterface $logger,
34 | protected readonly Config $config,
35 | protected readonly XmlConverter $xmlConverter,
36 | protected readonly IdeHandler $ideHandler,
37 | protected readonly XdebugHandler $xdebugHandler,
38 | ) {
39 | }
40 |
41 | /**
42 | * @throws UnsupportedFeatureException
43 | */
44 | public function run(): void
45 | {
46 | if (extension_loaded('pcntl')) {
47 | $terminateClosure = fn (string $callbackId, int $signal) => $this->terminate();
48 | EventLoop::onSignal(SIGTERM, $terminateClosure);
49 | EventLoop::onSignal(SIGINT, $terminateClosure);
50 | }
51 |
52 | async(fn () => $this->runIdeRegistration());
53 | async(fn () => $this->runXdebug());
54 | EventLoop::run();
55 | }
56 |
57 | /**
58 | * @throws SocketException
59 | */
60 | public function runXdebug(): void
61 | {
62 | $server = listen($this->config->getXdebugServer()->getListen());
63 | $this->servers[] = $server;
64 | $this->logger->notice("[Proxy][Xdebug] Listening for new connections on '{$server->getAddress()->toString()}'...");
65 | while ($client = $server->accept()) {
66 | async(fn () => $this->xdebugHandler->handle($client));
67 | }
68 | }
69 |
70 | /**
71 | * @throws SocketException
72 | */
73 | public function runIdeRegistration(): void
74 | {
75 | $listen = $this->config->getIdeRegistrationServer()->getListen();
76 | if (!$listen) {
77 | $this->logger->notice('[Proxy][IdeRegistration] IDE registration is disabled by config, skip it.');
78 |
79 | return;
80 | }
81 | $server = listen($listen);
82 | $this->servers[] = $server;
83 | $this->logger->notice(
84 | "[Proxy][IdeRegistration] Listening for new connections on '{$server->getAddress()->toString()}'..."
85 | );
86 | while ($socket = $server->accept()) {
87 | async(fn () => $this->ideHandler->handle($socket));
88 | }
89 | }
90 |
91 | public function terminate(): void
92 | {
93 | foreach ($this->servers as $server) {
94 | $server->close();
95 | }
96 | $this->logger->notice('[Proxy][Terminating] Terminating proxy server.');
97 | EventLoop::getDriver()->stop();
98 | $this->servers = [];
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/RequestPreparer/Error.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class Error extends RuntimeException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/RequestPreparer/Exception.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class Exception extends BaseException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/RequestPreparer/RequestPreparer.php:
--------------------------------------------------------------------------------
1 |
12 | * You can use request preparer for example for changing path to files (in break points and execution files).
13 | */
14 | interface RequestPreparer
15 | {
16 | /**
17 | * In this method you can change $xmlRequest, which will be sent to ide.
18 | *
19 | * @param XmlDocument $xmlRequest request from xdebug to ide
20 | * @param string $rawRequest just for logging purposes
21 | *
22 | * @throws Exception
23 | * @throws Error
24 | */
25 | public function prepareRequestToIde(XmlDocument $xmlRequest, string $rawRequest): void;
26 |
27 | /**
28 | * This method should return request based on $request, which will be sent to xdebug.
29 | * Use $commandToXdebugParser to parse the command in request and to rebuild the command.
30 | *
31 | * @param string $request command from ide to xdebug
32 | *
33 | * @throws Exception
34 | * @throws Error
35 | */
36 | public function prepareRequestToXdebug(string $request, CommandToXdebugParser $commandToXdebugParser): string;
37 | }
38 |
--------------------------------------------------------------------------------
/src/RequestPreparer/SoftMocksRequestPreparer.php:
--------------------------------------------------------------------------------
1 |
23 | */
24 | class SoftMocksRequestPreparer implements RequestPreparer
25 | {
26 | /**
27 | * @throws Error
28 | */
29 | public function __construct(
30 | protected readonly LoggerInterface $logger,
31 | string $initScript = '',
32 | ) {
33 | if (!$initScript) {
34 | $possibleInitScriptPaths = [
35 | __DIR__ . '/../../vendor/badoo/soft-mocks/src/init_with_composer.php',
36 | __DIR__ . '/../../../../badoo/soft-mocks/src/init_with_composer.php',
37 | ];
38 | foreach ($possibleInitScriptPaths as $possiblInitScriptPath) {
39 | if (file_exists($possiblInitScriptPath)) {
40 | $initScript = $possiblInitScriptPath;
41 |
42 | break;
43 | }
44 | }
45 | }
46 |
47 | if (!$initScript) {
48 | throw new Error("Can't find soft-mocks init script");
49 | }
50 | require $initScript;
51 | }
52 |
53 | public function prepareRequestToIde(XmlDocument $xmlRequest, string $rawRequest): void
54 | {
55 | $context = [
56 | 'request' => $rawRequest,
57 | ];
58 | $root = $xmlRequest->getRoot();
59 | if (!$root) {
60 | return;
61 | }
62 | foreach ($root->getChildren() as $child) {
63 | if (!in_array($child->getName(), ['stack', 'xdebug:message'], true)) {
64 | continue;
65 | }
66 | $attributes = $child->getAttributes();
67 | if (isset($attributes['filename'])) {
68 | $filename = $this->getOriginalFilePath($attributes['filename'], $context);
69 | if ($attributes['filename'] !== $filename) {
70 | $this->logger->info("Change '{$attributes['filename']}' to '{$filename}'", $context);
71 | $child->addAttribute('filename', $filename);
72 | }
73 | }
74 | }
75 | }
76 |
77 | /**
78 | * @param array $context
79 | */
80 | protected function getOriginalFilePath(string $file, array $context): string
81 | {
82 | // workaround some symbols like '+' are encoded like %2B
83 | $file = rawurldecode($file);
84 | $parts = parse_url($file);
85 | if ($parts === false) {
86 | $this->logger->warning("Can't parse file '{$file}'", $context);
87 |
88 | return $file;
89 | }
90 | /** @noinspection OffsetOperationsInspection */
91 | if (($parts['scheme'] ?? '') !== 'file') {
92 | $this->logger->warning("Scheme isn't file '{$file}'", $context);
93 |
94 | return $file;
95 | }
96 |
97 | try {
98 | /** @noinspection PhpUndefinedClassInspection */
99 | /** @noinspection OffsetOperationsInspection */
100 | /** @phpstan-ignore binaryOp.invalid, class.notFound */
101 | return 'file://' . SoftMocks::getOriginalFilePath($parts['path'] ?? '');
102 | } catch (Throwable $throwable) {
103 | $this->logger->warning("Can't get original file path: {$throwable}", $context);
104 |
105 | return $file;
106 | }
107 | }
108 |
109 | public function prepareRequestToXdebug(string $request, CommandToXdebugParser $commandToXdebugParser): string
110 | {
111 | [$command, $arguments] = $commandToXdebugParser->parseCommand($request);
112 | $context = [
113 | 'request' => $request,
114 | 'arguments' => $arguments,
115 | ];
116 | if ($command === 'breakpoint_set') {
117 | if (isset($arguments['-f'])) {
118 | $file = $this->getRewrittenFilePath($arguments['-f'], $context);
119 | if ($file) {
120 | $this->logger->info("Change '{$arguments['-f']}' to '{$file}'", $context);
121 | $arguments['-f'] = $file;
122 | $request = $commandToXdebugParser->buildCommand($command, $arguments);
123 | }
124 | } else {
125 | $this->logger->error("Command {$command} is without argument '-f'", $context);
126 | }
127 | }
128 |
129 | return $request;
130 | }
131 |
132 | /**
133 | * @param array $context
134 | */
135 | protected function getRewrittenFilePath(string $file, array $context): string
136 | {
137 | $originalFile = $file;
138 | $parts = parse_url($file);
139 | if ($parts === false) {
140 | $this->logger->warning("Can't parse file '{$file}'", $context);
141 |
142 | return '';
143 | }
144 | /** @noinspection OffsetOperationsInspection */
145 | if (($parts['scheme'] ?? '') !== 'file') {
146 | $this->logger->warning("Scheme isn't file '{$file}'", $context);
147 |
148 | return '';
149 | }
150 | try {
151 | /** @noinspection PhpUndefinedClassInspection */
152 | /** @noinspection OffsetOperationsInspection */
153 | /** @phpstan-ignore class.notFound, cast.string */
154 | $rewrittenFile = (string) SoftMocks::getRewrittenFilePath($parts['path'] ?? '');
155 | } catch (Throwable $throwable) {
156 | $this->logger->warning("Can't get rewritten file path: {$throwable}", $context);
157 |
158 | return '';
159 | }
160 | if (!$rewrittenFile) {
161 | return '';
162 | }
163 | if (is_file($rewrittenFile)) {
164 | $file = realpath($rewrittenFile);
165 | if (!$file) {
166 | $this->logger->error("Can't get real path for {$rewrittenFile}", $context);
167 | }
168 | } else {
169 | $this->logger->debug("Rewritten file '{$rewrittenFile}' isn't exists for '{$originalFile}'", $context);
170 | $file = $rewrittenFile;
171 | }
172 | if (!$file) {
173 | return '';
174 | }
175 |
176 | return 'file://' . $file;
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/src/RunError.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class RunError extends RuntimeException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Runner.php:
--------------------------------------------------------------------------------
1 |
29 | *
30 | * @phpstan-import-type XdebugProxyConfigArray from Config
31 | */
32 | class Runner
33 | {
34 | public function run(): void
35 | {
36 | try {
37 | $options = $this->getOptions();
38 |
39 | if (isset($options['help'])) {
40 | $this->showHelp();
41 | }
42 |
43 | $configsPath = $this->getConfigsPath($options);
44 |
45 | $logger = $this->getLogger($configsPath);
46 |
47 | $factory = $this->getFactory($configsPath);
48 | $config = $this->getConfig($configsPath, $factory);
49 | $xmlConverter = $factory->createXmlConverter($logger);
50 | $requestPreparers = [];
51 | try {
52 | $requestPreparers = $factory->createRequestPreparers($logger, $config);
53 | } catch (RequestPreparerException $exception) {
54 | $logger->warning("Can't create request preparers: {$exception}");
55 | } catch (RequestPreparerError $exception) {
56 | $logger->critical("Can't create request preparers: {$exception}");
57 | $this->end(1);
58 | }
59 | $ideHandler = $factory->createIdeHandler(
60 | $logger,
61 | $config->getIdeServer(),
62 | $xmlConverter,
63 | $requestPreparers
64 | );
65 | $xdebugHandler = $factory->createXdebugHandler($logger, $xmlConverter, $ideHandler);
66 | $factory->createProxy($logger, $config, $xmlConverter, $ideHandler, $xdebugHandler)
67 | ->run();
68 | } catch (RunError|UnsupportedFeatureException $error) {
69 | $this->errorFallback('');
70 | $this->errorFallback('There is error:');
71 | $this->errorFallback($error->__toString());
72 | $this->errorFallback('');
73 | $this->showHelp($error->getCode() ?: 1);
74 | }
75 | }
76 |
77 | #[NoReturn]
78 | protected function showHelp(?int $exitCode = null): void
79 | {
80 | if ($exitCode === null) {
81 | $exitCode = 0;
82 | }
83 | $this->infoFallback('Usage:');
84 | $this->infoFallback(" {$this->getScriptName()} [options]");
85 | $this->infoFallback('');
86 | $this->infoFallback('Mandatory arguments to long options are mandatory for short options too.');
87 | $this->infoFallback('Options:');
88 | $this->infoFallback(' -h, --help This help.');
89 | $this->infoFallback(' -c, --configs=PATH Path to directory with configs:');
90 | $this->infoFallback(' - config.php: you can customize listen ip and port;');
91 | $this->infoFallback(' - logger.php: you can customize logger, file should return object, which is instanceof \Psr\Log\LoggerInterface;');
92 | $this->infoFallback(' - factory.php: you can customize classes, which is used in proxy, file should return object, which is instanceof \Mougrim\XdebugProxy\Factory\Factory.');
93 | $this->infoFallback('');
94 | $this->infoFallback('Documentation: .');
95 | $this->infoFallback('');
96 | $this->end($exitCode);
97 | }
98 |
99 | /**
100 | * @return array{configs?: string, help?: false}
101 | */
102 | protected function getOptions(): array
103 | {
104 | $shortToLongOptions = [
105 | 'c' => 'configs',
106 | 'h' => 'help',
107 | ];
108 | /** @var array $rawOptions */
109 | $rawOptions = getopt('c:h', ['configs:', 'help']);
110 | $result = [];
111 | foreach ($shortToLongOptions as $shortOption => $longOption) {
112 | if (isset($rawOptions[$shortOption])) {
113 | $result[$longOption] = $rawOptions[$shortOption];
114 | } elseif (isset($rawOptions[$longOption])) {
115 | $result[$longOption] = $rawOptions[$longOption];
116 | }
117 | }
118 |
119 | /** @phpstan-ignore return.type */
120 | return $result;
121 | }
122 |
123 | /**
124 | * @param array{configs?: string} $options
125 | */
126 | protected function getConfigsPath(array $options): string
127 | {
128 | $configPath = $options['configs'] ?? __DIR__ . '/../config';
129 | $realConfigPath = realpath($configPath);
130 | if (!$realConfigPath || !is_dir($realConfigPath)) {
131 | throw new RunError("Wrong config path {$configPath}", 1);
132 | }
133 | $this->infoFallback("Using config path {$realConfigPath}");
134 |
135 | return $realConfigPath;
136 | }
137 |
138 | protected function getConfig(string $configsPath, Factory $factory): Config
139 | {
140 | $configPath = $configsPath . '/config.php';
141 | $config = $this->requireConfig($configPath);
142 | if (!is_array($config)) {
143 | throw new RunError("Config '{$configPath}' should return array.");
144 | }
145 | /** @var XdebugProxyConfigArray $config */
146 |
147 | return $factory->createConfig($config);
148 | }
149 |
150 | protected function getFactory(string $configsPath): Factory
151 | {
152 | $factoryConfigPath = $configsPath . '/factory.php';
153 | $factory = $this->requireConfig($factoryConfigPath);
154 | if (!$factory instanceof Factory) {
155 | throw new RunError("Factory config '{$factoryConfigPath}' should return Factory object.");
156 | }
157 |
158 | return $factory;
159 | }
160 |
161 | protected function getLogger(string $configsPath): LoggerInterface
162 | {
163 | $loggerConfigPath = $configsPath . '/logger.php';
164 | $logger = $this->requireConfig($loggerConfigPath);
165 | if (!$logger instanceof LoggerInterface) {
166 | throw new RunError("Logger config '{$loggerConfigPath}' should return LoggerInterface object.");
167 | }
168 |
169 | return $logger;
170 | }
171 |
172 | /**
173 | * @throws RunError
174 | */
175 | protected function requireConfig(string $path): mixed
176 | {
177 | if (!is_file($path) || !is_readable($path)) {
178 | throw new RunError("Wrong config path {$path}.");
179 | }
180 | return require $path;
181 | }
182 |
183 | public function getScriptName(): string
184 | {
185 | /** @var array $argv */
186 | $argv = $_SERVER['argv'];
187 |
188 | return $argv[0] ?? 'xdebug-proxy';
189 | }
190 |
191 | #[NoReturn]
192 | protected function end(int $exitCode): void
193 | {
194 | exit($exitCode);
195 | }
196 |
197 | protected function errorFallback(string $message): void
198 | {
199 | fwrite(STDERR, $message . PHP_EOL);
200 | }
201 |
202 | protected function infoFallback(string $message): void
203 | {
204 | fwrite(STDOUT, $message . PHP_EOL);
205 | }
206 | }
207 |
--------------------------------------------------------------------------------
/src/Xml/DomXmlConverter.php:
--------------------------------------------------------------------------------
1 |
42 | */
43 | class DomXmlConverter implements XmlConverter
44 | {
45 | public function __construct(
46 | protected readonly LoggerInterface $logger,
47 | ) {
48 | }
49 |
50 | protected function getError(int $type): string
51 | {
52 | return match ($type) {
53 | E_ERROR => 'E_ERROR',
54 | E_WARNING => 'E_WARNING',
55 | E_PARSE => 'E_PARSE',
56 | E_NOTICE => 'E_NOTICE',
57 | E_CORE_ERROR => 'E_CORE_ERROR',
58 | E_CORE_WARNING => 'E_CORE_WARNING',
59 | E_COMPILE_ERROR => 'E_COMPILE_ERROR',
60 | E_COMPILE_WARNING => 'E_COMPILE_WARNING',
61 | E_USER_ERROR => 'E_USER_ERROR',
62 | E_USER_WARNING => 'E_USER_WARNING',
63 | E_USER_NOTICE => 'E_USER_NOTICE',
64 | E_STRICT => 'E_STRICT',
65 | E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR',
66 | E_DEPRECATED => 'E_DEPRECATED',
67 | E_USER_DEPRECATED => 'E_USER_DEPRECATED',
68 | default => "[{$type}] Unknown",
69 | };
70 | }
71 |
72 | /**
73 | * @throws XmlException
74 | */
75 | public function parse(string $xml): XmlDocument
76 | {
77 | if (!$xml) {
78 | throw new XmlParseException("Can't parse xml: xml must not be empty");
79 | }
80 | $domDocument = new DOMDocument();
81 | $result = @$domDocument->loadXML($xml, LIBXML_NONET);
82 | if (!$result) {
83 | $error = error_get_last();
84 | error_clear_last();
85 | $message = '';
86 | if ($error) {
87 | $message = ": [{$this->getError($error['type'])}] {$error['message']} in {$error['file']}:{$error['line']}";
88 | }
89 | throw new XmlParseException("Can't parse xml{$message}");
90 | }
91 |
92 | return $this->toDocument($domDocument);
93 | }
94 |
95 | /**
96 | * @throws XmlParseException
97 | */
98 | protected function toDocument(DOMDocument $domDocument): XmlDocument
99 | {
100 | if (count($domDocument->childNodes) > 1) {
101 | throw new XmlParseException('Too many child nodes in document');
102 | }
103 |
104 | $root = null;
105 | if ($domDocument->documentElement) {
106 | $root = $this->toContainer($domDocument, $domDocument->documentElement);
107 | }
108 |
109 | return new XmlDocument($domDocument->xmlVersion ?? '', $domDocument->xmlEncoding, $root);
110 | }
111 |
112 | /**
113 | * @throws XmlParseException
114 | */
115 | protected function toContainer(DOMDocument $domDocument, DOMElement $domElement): XmlContainer
116 | {
117 | $container = new XmlContainer($domElement->tagName);
118 | $xpath = new DOMXPath($domDocument);
119 | $nodes = $xpath->query('namespace::*|attribute::*', $domElement);
120 | if ($nodes) {
121 | foreach ($nodes as /** @var DOMNode $node */ $node) {
122 | if (!$domElement->hasAttribute($node->nodeName)) {
123 | continue;
124 | }
125 | $container->addAttribute($node->nodeName, $node->nodeValue ?? '');
126 | }
127 | }
128 | $content = '';
129 | foreach ($domElement->childNodes as /** @var DOMNode $child */ $child) {
130 | if ($child instanceof DOMElement) {
131 | $container->addChild($this->toContainer($domDocument, $child));
132 | } elseif ($child instanceof DOMText) {
133 | if ($child instanceof DOMCdataSection) {
134 | $container->setIsContentCdata(true);
135 | }
136 | $content .= ($child->nodeValue ?? '');
137 | } elseif ($child instanceof DOMEntityReference) {
138 | throw new XmlParseException('Xml should be without entity ref nodes');
139 | } else {
140 | $this->logger->warning(
141 | "Unknown element child node type {$child->nodeType}, skip it",
142 | [
143 | 'xml' => $domDocument->saveXML(),
144 | ]
145 | );
146 | }
147 | }
148 | $container->setContent($content);
149 |
150 | return $container;
151 | }
152 |
153 | /**
154 | * @throws XmlException
155 | */
156 | public function generate(XmlDocument $document): string
157 | {
158 | try {
159 | $domDocument = new DOMDocument($document->getVersion(), $document->getEncoding() ?? '');
160 | $xmlContainer = $document->getRoot();
161 | if ($xmlContainer) {
162 | $domElement = $this->toDomElement($domDocument, $xmlContainer);
163 | $domDocument->appendChild($domElement);
164 | }
165 |
166 | $xml = @$domDocument->saveXML();
167 |
168 | if ($xml === false) {
169 | $error = error_get_last();
170 | error_clear_last();
171 | $message = '';
172 | if ($error) {
173 | $message = ": [{$this->getError($error['type'])}] {$error['message']} in {$error['file']}:{$error['line']}";
174 | }
175 |
176 | throw new XmlValidateException("Can't generate xml{$message}");
177 | }
178 |
179 | return $xml;
180 | } catch (DOMException $exception) {
181 | throw new XmlValidateException("Can't generate xml", 0, $exception);
182 | }
183 | }
184 |
185 | /**
186 | * @throws DOMException
187 | */
188 | protected function toDomElement(DOMDocument $domDocument, XmlContainer $container): DOMElement
189 | {
190 | $domElement = $domDocument->createElement($container->getName());
191 | if ($container->getContent()) {
192 | if ($container->isContentCdata()) {
193 | $domContent = $domDocument->createCDATASection($container->getContent());
194 | } else {
195 | $domContent = $domDocument->createTextNode($container->getContent());
196 | }
197 | if ($domContent) {
198 | $domElement->appendChild($domContent);
199 | }
200 | }
201 | foreach ($container->getAttributes() as $name => $value) {
202 | $domElement->setAttribute($name, $value);
203 | }
204 | foreach ($container->getChildren() as $child) {
205 | $domElement->appendChild($this->toDomElement($domDocument, $child));
206 | }
207 |
208 | return $domElement;
209 | }
210 | }
211 |
--------------------------------------------------------------------------------
/src/Xml/XmlContainer.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * Actually XmlContainerArray is recursive, but phpstan doesn't support recursive types
11 | *
12 | * @phpstan-type XmlContainerArray array{name: string, attributes: array, content: string, isContentCdata: bool, children: array{string, mixed}}
13 | */
14 | class XmlContainer
15 | {
16 | /** @var array */
17 | protected array $attributes = [];
18 | protected string $content = '';
19 | protected bool $isContentCdata = false;
20 | /** @var XmlContainer[] */
21 | protected array $children = [];
22 |
23 | public function __construct(
24 | protected readonly string $name,
25 | ) {
26 | }
27 |
28 | public function getName(): string
29 | {
30 | return $this->name;
31 | }
32 |
33 | public function getAttribute(string $name): ?string
34 | {
35 | return $this->attributes[$name] ?? null;
36 | }
37 |
38 | /**
39 | * @return array
40 | */
41 | public function getAttributes(): array
42 | {
43 | return $this->attributes;
44 | }
45 |
46 | public function addAttribute(string $name, string $value): static
47 | {
48 | $this->attributes[$name] = $value;
49 |
50 | return $this;
51 | }
52 |
53 | /**
54 | * @param array $attributes
55 | */
56 | public function setAttributes(array $attributes): static
57 | {
58 | $this->attributes = $attributes;
59 |
60 | return $this;
61 | }
62 |
63 | public function getContent(): string
64 | {
65 | return $this->content;
66 | }
67 |
68 | public function setContent(string $content): static
69 | {
70 | $this->content = $content;
71 |
72 | return $this;
73 | }
74 |
75 | public function isContentCdata(): bool
76 | {
77 | return $this->isContentCdata;
78 | }
79 |
80 | public function setIsContentCdata(bool $isContentCdata): static
81 | {
82 | $this->isContentCdata = $isContentCdata;
83 |
84 | return $this;
85 | }
86 |
87 | /**
88 | * @return XmlContainer[]
89 | */
90 | public function getChildren(): array
91 | {
92 | return $this->children;
93 | }
94 |
95 | public function addChild(XmlContainer $child): static
96 | {
97 | $this->children[] = $child;
98 |
99 | return $this;
100 | }
101 |
102 | /**
103 | * @param XmlContainer[] $children
104 | */
105 | public function setChildren(array $children): static
106 | {
107 | $this->children = $children;
108 |
109 | return $this;
110 | }
111 |
112 | /**
113 | * @phpstan-return XmlContainerArray
114 | */
115 | public function toArray(): array
116 | {
117 | $children = [];
118 | foreach ($this->children as $child) {
119 | $children[] = $child->toArray();
120 | }
121 |
122 | /** @phpstan-ignore return.type */
123 | return [
124 | 'name' => $this->name,
125 | 'attributes' => $this->attributes,
126 | 'content' => $this->content,
127 | 'isContentCdata' => $this->isContentCdata,
128 | 'children' => $children,
129 | ];
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/Xml/XmlConverter.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | interface XmlConverter
11 | {
12 | /**
13 | * @throws XmlException
14 | */
15 | public function parse(string $xml): XmlDocument;
16 |
17 | /**
18 | * @throws XmlException
19 | */
20 | public function generate(XmlDocument $document): string;
21 | }
22 |
--------------------------------------------------------------------------------
/src/Xml/XmlDocument.php:
--------------------------------------------------------------------------------
1 |
9 | *
10 | * @phpstan-import-type XmlContainerArray from XmlContainer
11 | */
12 | class XmlDocument
13 | {
14 | public function __construct(
15 | protected readonly string $version,
16 | protected readonly ?string $encoding = null,
17 | protected readonly ?XmlContainer $root = null,
18 | ) {
19 | }
20 |
21 | public function getVersion(): string
22 | {
23 | return $this->version;
24 | }
25 |
26 | public function getEncoding(): ?string
27 | {
28 | return $this->encoding;
29 | }
30 |
31 | public function getRoot(): ?XmlContainer
32 | {
33 | return $this->root;
34 | }
35 |
36 | /**
37 | * @return array{version: string, encoding: ?string, root: ?XmlContainerArray}
38 | */
39 | public function toArray(): array
40 | {
41 | return [
42 | 'version' => $this->version,
43 | 'encoding' => $this->encoding,
44 | 'root' => $this->root?->toArray(),
45 | ];
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/Xml/XmlException.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class XmlException extends Exception
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Xml/XmlParseException.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | class XmlParseException extends XmlException
11 | {
12 | }
13 |
--------------------------------------------------------------------------------
/src/Xml/XmlValidateException.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | class XmlValidateException extends XmlException
11 | {
12 | }
13 |
--------------------------------------------------------------------------------
/tests/TestCase.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | class TestCase extends PHPUnitTestCase
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/tests/Unit/Xml/DomXmlConverterTest.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | class DomXmlConverterTest extends TestCase
26 | {
27 | public function testParseGenerate(): void
28 | {
29 | $xml = '
30 | Child1Child2SubChild1SubChild2
31 | ';
32 | $converter = new DomXmlConverter($this->createFakeLogger());
33 | $document = $converter->parse($xml);
34 |
35 | static::assertSame('1.0', $document->getVersion());
36 | static::assertSame('UTF-8', $document->getEncoding());
37 | $root = $document->getRoot();
38 | static::assertNotEmpty($root);
39 | static::assertSame('root', $root->getName());
40 | static::assertFalse($root->isContentCdata(), "Content shouldn't be cdata");
41 | static::assertEmpty($root->getContent(), "Content: {$root->getContent()}");
42 | static::assertEmpty($root->getAttributes());
43 | static::assertCount(2, $root->getChildren());
44 | static::assertSame('child', $root->getChildren()[0]->getName());
45 | static::assertFalse($root->getChildren()[0]->isContentCdata(), "Content shouldn't be cdata");
46 | static::assertSame('Child1', $root->getChildren()[0]->getContent());
47 | static::assertSame(['id' => '1'], $root->getChildren()[0]->getAttributes());
48 | static::assertEmpty($root->getChildren()[0]->getChildren());
49 | static::assertSame('child', $root->getChildren()[1]->getName());
50 | static::assertFalse($root->getChildren()[1]->isContentCdata(), "Content shouldn't be cdata");
51 | static::assertSame('Child2', $root->getChildren()[1]->getContent());
52 | static::assertSame(['id' => '2'], $root->getChildren()[1]->getAttributes());
53 | $subChildren = $root->getChildren()[1]->getChildren();
54 | static::assertCount(2, $subChildren);
55 | static::assertSame('sub-child', $subChildren[0]->getName());
56 | static::assertFalse($subChildren[0]->isContentCdata(), "Content shouldn't be cdata");
57 | static::assertSame('SubChild1', $subChildren[0]->getContent());
58 | static::assertEmpty($subChildren[0]->getAttributes());
59 | static::assertEmpty($subChildren[0]->getChildren());
60 | static::assertSame('sub-child', $subChildren[1]->getName());
61 | static::assertFalse($subChildren[1]->isContentCdata(), "Content shouldn't be cdata");
62 | static::assertSame('SubChild2', $subChildren[1]->getContent());
63 | static::assertEmpty($subChildren[1]->getAttributes());
64 | static::assertEmpty($subChildren[1]->getChildren());
65 | static::assertSame(
66 | [
67 | 'version' => '1.0',
68 | 'encoding' => 'UTF-8',
69 | 'root' => [
70 | 'name' => 'root',
71 | 'attributes' => [],
72 | 'content' => '',
73 | 'isContentCdata' => false,
74 | 'children' => [
75 | [
76 | 'name' => 'child',
77 | 'attributes' => [
78 | 'id' => '1',
79 | ],
80 | 'content' => 'Child1',
81 | 'isContentCdata' => false,
82 | 'children' => [],
83 | ],
84 | [
85 | 'name' => 'child',
86 | 'attributes' => [
87 | 'id' => '2',
88 | ],
89 | 'content' => 'Child2',
90 | 'isContentCdata' => false,
91 | 'children' => [
92 | [
93 | 'name' => 'sub-child',
94 | 'attributes' => [],
95 | 'content' => 'SubChild1',
96 | 'isContentCdata' => false,
97 | 'children' => [],
98 | ],
99 | [
100 | 'name' => 'sub-child',
101 | 'attributes' => [],
102 | 'content' => 'SubChild2',
103 | 'isContentCdata' => false,
104 | 'children' => [],
105 | ],
106 | ],
107 | ],
108 | ],
109 | ],
110 | ],
111 | $document->toArray()
112 | );
113 |
114 | static::assertSame($xml, $converter->generate($document));
115 | }
116 |
117 | public function testParseGenerateEscaping(): void
118 | {
119 | $xml = '
120 | </root><root>xss
121 | ';
122 | $converter = new DomXmlConverter($this->createFakeLogger());
123 | $document = $converter->parse($xml);
124 | static::assertSame('1.0', $document->getVersion());
125 | static::assertSame('UTF-8', $document->getEncoding());
126 | $root = $document->getRoot();
127 | static::assertSame('root', $root?->getName());
128 | static::assertFalse($root->isContentCdata(), "Content shouldn't be cdata");
129 | static::assertSame('xss', $root->getContent());
130 | static::assertSame(['attribute' => '">xss'], $root->getAttributes());
131 | static::assertEmpty($root->getChildren());
132 | static::assertSame(
133 | [
134 | 'version' => '1.0',
135 | 'encoding' => 'UTF-8',
136 | 'root' => [
137 | 'name' => 'root',
138 | 'attributes' => [
139 | 'attribute' => '">xss',
140 | ],
141 | 'content' => 'xss',
142 | 'isContentCdata' => false,
143 | 'children' => [],
144 | ],
145 | ],
146 | $document->toArray()
147 | );
148 | static::assertSame($xml, $converter->generate($document));
149 | }
150 |
151 | public function testWrongNameDecoding(): void
152 | {
153 | $xml = '
154 | <<rootattribute="value1"> attribute="value2">Content<rootattribute="value1">>
155 | ';
156 | $converter = new DomXmlConverter($this->createFakeLogger());
157 | $this->expectException(XmlParseException::class);
158 | $this->expectExceptionMessage("Can't parse xml");
159 | $converter->parse($xml);
160 | }
161 |
162 | public function testParseEmptyMessage(): void
163 | {
164 | $converter = new DomXmlConverter($this->createFakeLogger());
165 | $this->expectException(XmlParseException::class);
166 | $this->expectExceptionMessage("Can't parse xml");
167 | $converter->parse('');
168 | }
169 |
170 | public function testNameValidate(): void
171 | {
172 | $root = new XmlContainer(htmlspecialchars('', ENT_XML1 | ENT_QUOTES));
173 | $document = new XmlDocument('1.0', 'UTF-8', $root);
174 | $converter = new DomXmlConverter($this->createFakeLogger());
175 | $this->expectException(XmlValidateException::class);
176 | $this->expectExceptionMessage("Can't generate xml");
177 | $converter->generate($document);
178 | }
179 |
180 | public function testAttributeNameValidate(): void
181 | {
182 | $root = (new XmlContainer('root'))
183 | ->addAttribute(
184 | htmlspecialchars('>XSSgenerate($document);
192 | }
193 |
194 | /**
195 | * @see https://www.owasp.org/index.php/XML_External_Entity_(XXE)_Processing
196 | */
197 | public function testXxe(): void
198 | {
199 | /** @noinspection CheckDtdRefs */
200 | $xml = '
201 |
203 | ]>
204 | &harmless;
205 | ';
206 |
207 | $converter = new DomXmlConverter($this->createFakeLogger());
208 | $this->expectException(XmlParseException::class);
209 | $this->expectExceptionMessageMatches(
210 | '/^(?:Xml should be without entity ref nodes|Too many child nodes in document)$/'
211 | );
212 | $converter->parse($xml);
213 | }
214 |
215 | public function testXdebugInit(): void
216 | {
217 | $xml = '
218 |
219 | ';
220 |
221 | $converter = new DomXmlConverter($this->createFakeLogger());
222 | $document = $converter->parse($xml);
223 |
224 | static::assertSame('1.0', $document->getVersion());
225 | static::assertSame('iso-8859-1', $document->getEncoding());
226 | $root = $document->getRoot();
227 |
228 | static::assertNotEmpty($root);
229 | static::assertSame('init', $root->getName());
230 | static::assertFalse($root->isContentCdata(), "Content shouldn't be cdata");
231 | static::assertEmpty($root->getContent(), "Content: {$root->getContent()}");
232 | static::assertSame(
233 | [
234 | 'xmlns:xdebug' => 'http://xdebug.org/dbgp/xdebug',
235 | 'xmlns' => 'urn:debugger_protocol_v1',
236 | 'fileuri' => 'file:///path/to/file.php',
237 | 'language' => 'PHP',
238 | 'xdebug:language_version' => '7.1.15-1+os1.02.3+some.site.org+1',
239 | 'protocol_version' => '1.0',
240 | 'appid' => '15603',
241 | 'idekey' => 'idekey',
242 | ],
243 | $root->getAttributes()
244 | );
245 | static::assertCount(4, $root->getChildren());
246 |
247 | $children = $root->getChildren();
248 |
249 | static::assertSame('engine', $children[0]->getName());
250 | static::assertTrue($children[0]->isContentCdata(), 'Content should be cdata');
251 | static::assertSame('Xdebug', $children[0]->getContent());
252 | static::assertSame(
253 | [
254 | 'version' => '2.6.0',
255 | ],
256 | $children[0]->getAttributes()
257 | );
258 | static::assertCount(0, $children[0]->getChildren());
259 |
260 | static::assertSame('author', $children[1]->getName());
261 | static::assertTrue($children[1]->isContentCdata(), 'Content should be cdata');
262 | static::assertSame('Derick Rethans', $children[1]->getContent());
263 | static::assertEmpty($children[1]->getAttributes());
264 | static::assertCount(0, $children[1]->getChildren());
265 |
266 | static::assertSame('url', $children[2]->getName());
267 | static::assertTrue($children[2]->isContentCdata(), 'Content should be cdata');
268 | static::assertSame('http://xdebug.org', $children[2]->getContent());
269 | static::assertEmpty($children[2]->getAttributes());
270 | static::assertCount(0, $children[2]->getChildren());
271 |
272 | static::assertSame('copyright', $children[3]->getName());
273 | static::assertTrue($children[3]->isContentCdata(), 'Content should be cdata');
274 | static::assertSame('Copyright (c) 2002-2018 by Derick Rethans', $children[3]->getContent());
275 | static::assertEmpty($children[3]->getAttributes());
276 | static::assertCount(0, $children[3]->getChildren());
277 |
278 | static::assertSame(
279 | [
280 | 'version' => '1.0',
281 | 'encoding' => 'iso-8859-1',
282 | 'root' => [
283 | 'name' => 'init',
284 | 'attributes' => [
285 | 'xmlns:xdebug' => 'http://xdebug.org/dbgp/xdebug',
286 | 'xmlns' => 'urn:debugger_protocol_v1',
287 | 'fileuri' => 'file:///path/to/file.php',
288 | 'language' => 'PHP',
289 | 'xdebug:language_version' => '7.1.15-1+os1.02.3+some.site.org+1',
290 | 'protocol_version' => '1.0',
291 | 'appid' => '15603',
292 | 'idekey' => 'idekey',
293 | ],
294 | 'content' => '',
295 | 'isContentCdata' => false,
296 | 'children' => [
297 | [
298 | 'name' => 'engine',
299 | 'attributes' => [
300 | 'version' => '2.6.0',
301 | ],
302 | 'content' => 'Xdebug',
303 | 'isContentCdata' => true,
304 | 'children' => [],
305 | ],
306 | [
307 | 'name' => 'author',
308 | 'attributes' => [],
309 | 'content' => 'Derick Rethans',
310 | 'isContentCdata' => true,
311 | 'children' => [],
312 | ],
313 | [
314 | 'name' => 'url',
315 | 'attributes' => [],
316 | 'content' => 'http://xdebug.org',
317 | 'isContentCdata' => true,
318 | 'children' => [],
319 | ],
320 | [
321 | 'name' => 'copyright',
322 | 'attributes' => [],
323 | 'content' => 'Copyright (c) 2002-2018 by Derick Rethans',
324 | 'isContentCdata' => true,
325 | 'children' => [],
326 | ],
327 | ],
328 | ],
329 | ],
330 | $document->toArray()
331 | );
332 |
333 | static::assertSame($xml, $converter->generate($document));
334 | }
335 |
336 | public function testXdebugMessage(): void
337 | {
338 | $xml = '
339 | ';
340 |
341 | $converter = new DomXmlConverter($this->createFakeLogger());
342 | $document = $converter->parse($xml);
343 |
344 | static::assertSame('1.0', $document->getVersion());
345 | static::assertSame('iso-8859-1', $document->getEncoding());
346 | $root = $document->getRoot();
347 |
348 | static::assertNotEmpty($root);
349 | static::assertSame('response', $root->getName());
350 | static::assertFalse($root->isContentCdata(), "Content shouldn't be cdata");
351 | static::assertEmpty($root->getContent(), "Content: {$root->getContent()}");
352 | static::assertSame(
353 | [
354 | 'xmlns:xdebug' => 'http://xdebug.org/dbgp/xdebug',
355 | 'xmlns' => 'urn:debugger_protocol_v1',
356 | 'command' => 'step_into',
357 | 'transaction_id' => '8',
358 | 'status' => 'break',
359 | 'reason' => 'ok',
360 | ],
361 | $root->getAttributes()
362 | );
363 | static::assertCount(1, $root->getChildren());
364 |
365 | $children = $root->getChildren();
366 |
367 | static::assertSame('xdebug:message', $children[0]->getName());
368 | static::assertFalse($children[0]->isContentCdata(), 'Content shouldn\'t be cdata');
369 | static::assertEmpty($children[0]->getContent(), "Content: {$children[0]->getContent()}");
370 | static::assertSame(
371 | [
372 | 'filename' => 'file:///home/mougrim/Private/development/mougrim/php-xdebug-proxy/bin/test.php',
373 | 'lineno' => '11',
374 | ],
375 | $children[0]->getAttributes()
376 | );
377 | static::assertCount(0, $children[0]->getChildren());
378 |
379 | static::assertSame(
380 | [
381 | 'version' => '1.0',
382 | 'encoding' => 'iso-8859-1',
383 | 'root' => [
384 | 'name' => 'response',
385 | 'attributes' => [
386 | 'xmlns:xdebug' => 'http://xdebug.org/dbgp/xdebug',
387 | 'xmlns' => 'urn:debugger_protocol_v1',
388 | 'command' => 'step_into',
389 | 'transaction_id' => '8',
390 | 'status' => 'break',
391 | 'reason' => 'ok',
392 | ],
393 | 'content' => '',
394 | 'isContentCdata' => false,
395 | 'children' => [
396 | [
397 | 'name' => 'xdebug:message',
398 | 'attributes' => [
399 | 'filename' => 'file:///home/mougrim/Private/development/mougrim/php-xdebug-proxy/bin/test.php',
400 | 'lineno' => '11',
401 | ],
402 | 'content' => '',
403 | 'isContentCdata' => false,
404 | 'children' => [],
405 | ],
406 | ],
407 | ],
408 | ],
409 | $document->toArray()
410 | );
411 | }
412 |
413 | protected function createFakeLogger(): LoggerInterface
414 | {
415 | return (new Logger('fake'))
416 | ->pushHandler(new NullHandler());
417 | }
418 | }
419 |
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 |
6 | */
7 |
8 | namespace Tests\Mougrim\XdebugProxy;
9 |
10 | use function dirname;
11 |
12 | require dirname(__DIR__) . '/vendor/autoload.php';
13 |
--------------------------------------------------------------------------------