From c78b9831b14094d2ae9fae41af1b6d95e3ad5a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Mon, 8 Jun 2026 16:37:30 +0300 Subject: [PATCH 1/2] Add gitattributes, ci workflow, gitignore, php-cs-fixer config, changelog --- .gitattributes | 11 + .github/workflows/ci.yml | 133 +++++++ .gitignore | 6 +- .php-cs-fixer.dist.php | 32 ++ CHANGELOG.md | 77 ++++ README.md | 175 +++++---- composer.json | 74 +++- docs/README.md | 36 ++ docs/api-reference.md | 108 ++++++ docs/env-file-format.md | 101 +++++ docs/exceptions.md | 48 +++ docs/getting-started.md | 84 ++++ docs/php-env-file.md | 65 ++++ docs/security.md | 46 +++ docs/value-types.md | 80 ++++ docs/variable-interpolation.md | 80 ++++ phpstan.neon.dist | 14 + phpunit.xml.dist | 26 ++ src/DotENV.php | 76 +++- src/Exceptions/DotENVException.php | 46 ++- src/Lib.php | 286 ++------------ src/Repository.php | 487 ++++++++++++++++++++++++ src/helpers.php | 54 +-- tests/CreateTest.php | 129 +++++++ tests/DotEnvTestCase.php | 147 +++++++ tests/EnvHelperTest.php | 34 ++ tests/ExceptionTest.php | 34 ++ tests/FacadeTest.php | 65 ++++ tests/InterpolationTest.php | 71 ++++ tests/LibBackwardsCompatibilityTest.php | 36 ++ tests/ParsingTest.php | 131 +++++++ tests/PhpEnvFileTest.php | 77 ++++ tests/PriorityTest.php | 66 ++++ tests/ValueTypeTest.php | 96 +++++ 34 files changed, 2618 insertions(+), 413 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .php-cs-fixer.dist.php create mode 100644 CHANGELOG.md create mode 100644 docs/README.md create mode 100644 docs/api-reference.md create mode 100644 docs/env-file-format.md create mode 100644 docs/exceptions.md create mode 100644 docs/getting-started.md create mode 100644 docs/php-env-file.md create mode 100644 docs/security.md create mode 100644 docs/value-types.md create mode 100644 docs/variable-interpolation.md create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Repository.php create mode 100644 tests/CreateTest.php create mode 100644 tests/DotEnvTestCase.php create mode 100644 tests/EnvHelperTest.php create mode 100644 tests/ExceptionTest.php create mode 100644 tests/FacadeTest.php create mode 100644 tests/InterpolationTest.php create mode 100644 tests/LibBackwardsCompatibilityTest.php create mode 100644 tests/ParsingTest.php create mode 100644 tests/PhpEnvFileTest.php create mode 100644 tests/PriorityTest.php create mode 100644 tests/ValueTypeTest.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8b670db --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Exclude development-only files from Composer dist tarballs. +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/.phpunit.cache export-ignore +/docs export-ignore +/tests export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/CHANGELOG.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a3115a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,133 @@ +name: CI + +on: + push: + branches: [main, "*.x"] + pull_request: + branches: [main, "*.x"] + +jobs: + validate: + name: composer validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer:v2 + - run: composer validate --strict + + cs: + name: PHP-CS-Fixer + runs-on: ubuntu-latest + needs: validate + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer:v2 + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + - name: Check coding standards + run: composer cs-check + + stan: + name: PHPStan + runs-on: ubuntu-latest + needs: validate + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer:v2 + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + - name: Run PHPStan + run: composer stan + + tests: + name: PHPUnit (PHP ${{ matrix.php }}, ${{ matrix.deps }}) + runs-on: ubuntu-latest + needs: validate + strategy: + fail-fast: false + matrix: + php: ["8.0", "8.1", "8.2", "8.3", "8.4"] + deps: ["highest"] + include: + - php: "8.0" + deps: "lowest" + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --no-check-publish + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ matrix.php }}-${{ matrix.deps }}-${{ hashFiles('**/composer.json') }} + restore-keys: composer-${{ matrix.php }}-${{ matrix.deps }}- + + - name: Install highest dependencies + if: matrix.deps == 'highest' + run: composer update --prefer-dist --no-progress --no-interaction + + - name: Install lowest dependencies + if: matrix.deps == 'lowest' + run: composer update --prefer-dist --no-progress --no-interaction --prefer-lowest --prefer-stable + + - name: Run PHPUnit + run: vendor/bin/phpunit + + coverage: + name: Coverage + runs-on: ubuntu-latest + needs: tests + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: pcov + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHPUnit with coverage + run: vendor/bin/phpunit --coverage-clover=coverage.xml + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-clover + path: coverage.xml + retention-days: 14 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + flags: phpunit + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index a879886..3d24f30 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ /.vs/ /.vscode/ /vendor/ -/composer.lock \ No newline at end of file +/composer.lock +/build/ +/.phpunit.cache/ +/.phpunit.result.cache +/.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..87406a8 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,32 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']) + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + '@PSR12:risky' => true, + '@PHP80Migration' => true, + '@PHP80Migration:risky' => true, + 'array_syntax' => ['syntax' => 'short'], + 'declare_strict_types' => true, + 'native_function_invocation' => [ + 'include' => ['@compiler_optimized'], + 'scope' => 'namespaced', + 'strict' => true, + ], + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'imports_order' => ['class', 'function', 'const'], + 'sort_algorithm' => 'alpha', + ], + 'single_quote' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + ]) + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/build/php-cs-fixer.cache'); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..073c136 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,77 @@ +# Changelog + +All notable changes to `initphp/dotenv` are documented here. The format is +based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [3.0.0] + +A maintenance-focused major release: real bug fixes, corrected value +semantics, a modern PHP 8 codebase, full tests, static analysis, CI and +documentation. The public API (`DotENV::create`, `get`, `env`, and the global +`env()` helper) is unchanged. + +### Requirements + +- **Raised the minimum PHP version to 8.0** (was 5.6). The whole library now + uses `declare(strict_types=1)` and native type declarations. + +### Fixed + +- **Directory-path loading.** `create('/some/dir')` was broken by a + `rtrim($dir . '\\/')` typo and always threw "could not be found in the + directory". It now correctly finds `.env` / `.env.php` inside a directory. +- **Multiple `${VAR}` references on one line.** A greedy pattern matched + across two references and resolved the value to an empty string. `${A}:${B}` + now expands both. +- **Quoted values with whitespace around `=`.** `KEY = "value"` kept its + quotes because the quote check ran on the untrimmed value. Quotes are now + stripped regardless of spacing. +- **Values beginning with `#`.** A value such as `#ffffff` was swallowed + entirely as a comment. An inline comment now only starts at a `#` preceded + by whitespace, so leading-`#` values survive. +- **Lines without `=`.** A non-comment line with no `=` raised PHP 8 + "undefined array key" / "passing null" warnings and stored a junk key. Such + lines are now ignored. +- **Circular `${VAR}` references** (`A=${A}`, or `B=${C}` / `C=${B}`) recursed + until the stack overflowed. They now resolve to an empty string. + +### Changed + +- **Loss-free numeric coercion.** Numbers are coerced to `int`/`float` only + when the conversion round-trips exactly. `007`, `+905551112233`, + values beyond `PHP_INT_MAX`, and `1e3` now stay strings instead of being + silently mangled. `13` and `3.14` still coerce as before. +- **Immutability check** now uses `array_key_exists()` instead of `isset()`, + so a pre-existing name whose value is `null` is still respected. +- Renamed the internal worker class `Lib` to **`Repository`**. `Lib` remains + available as a deprecated alias. +- Documentation, comments and PHPDoc are now in English and match the actual + behaviour. + +### Added + +- **`Repository::flush()`** and **`DotENV::flush()` / `DotENV::reset()`** to + unload values and reset the shared instance (useful in tests and workers). +- **`DotENV::instance()`** to access the shared repository. +- An optional leading **`export `** prefix on a key is now stripped. +- A full **PHPUnit** test suite, **PHPStan** (max level) configuration, + **PHP-CS-Fixer** configuration, a **GitHub Actions CI** workflow (PHP + 8.0–8.4), and a **`docs/`** directory. + +## [2.0.1] + +- Inline comment parsing fix. + +## [2.0] + +- Support for `.env.php` files and `${VAR}` interpolation. + +## [1.0] + +- Initial release. + +[3.0.0]: https://github.com/InitPHP/DotENV/releases/tag/3.0.0 +[2.0.1]: https://github.com/InitPHP/DotENV/releases/tag/2.0.1 +[2.0]: https://github.com/InitPHP/DotENV/releases/tag/2.0 +[1.0]: https://github.com/InitPHP/DotENV/releases/tag/1.0 diff --git a/README.md b/README.md index a9b7e3f..7695b88 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,136 @@ # InitPHP DotENV -Loads environment variables from `.env` or `.env.php` file. +Loads environment variables from a `.env` or `.env.php` file into PHP's +environment (`$_ENV`, `$_SERVER`, `getenv()`), with type coercion and +`${VAR}` interpolation. -[![Latest Stable Version](http://poser.pugx.org/initphp/dotenv/v)](https://packagist.org/packages/initphp/dotenv) [![Total Downloads](http://poser.pugx.org/initphp/dotenv/downloads)](https://packagist.org/packages/initphp/dotenv) [![Latest Unstable Version](http://poser.pugx.org/initphp/dotenv/v/unstable)](https://packagist.org/packages/initphp/dotenv) [![License](http://poser.pugx.org/initphp/dotenv/license)](https://packagist.org/packages/initphp/dotenv) [![PHP Version Require](http://poser.pugx.org/initphp/dotenv/require/php)](https://packagist.org/packages/initphp/dotenv) +[![CI](https://github.com/InitPHP/DotENV/actions/workflows/ci.yml/badge.svg)](https://github.com/InitPHP/DotENV/actions/workflows/ci.yml) +[![Latest Stable Version](http://poser.pugx.org/initphp/dotenv/v)](https://packagist.org/packages/initphp/dotenv) [![Total Downloads](http://poser.pugx.org/initphp/dotenv/downloads)](https://packagist.org/packages/initphp/dotenv) [![License](http://poser.pugx.org/initphp/dotenv/license)](https://packagist.org/packages/initphp/dotenv) [![PHP Version Require](http://poser.pugx.org/initphp/dotenv/require/php)](https://packagist.org/packages/initphp/dotenv) ## Requirements -- PHP 5.6 or higher +- PHP 8.0 or higher +- No extensions beyond the PHP core ## Installation -```php +```bash composer require initphp/dotenv ``` -## Usage +## Quick start -**Note :** Lines starting with any non-alphanumeric character are counted as comments and are not processed. +`/home/www/.env`: -**Note :** Existing definitions in the `$_SERVER` or `$_ENV` globals are not processed. - -### `.env` File - -_**Note that .env files are externally accessible. To prevent access with `.htaccess` or better yet keep your `.env` file in a directory that cannot be accessed externally.**_ - -`/home/www/.env` : - -``` -# Comment Line +```dotenv +# Comment line SITE_URL = http://lvh.me - PAGE_URL = ${SITE_URL}/page -; Comment Line -TRUE_VALE = true - -EMPTY_VALUE = empty - +; Another comment +TRUE_VALUE = true FALSE_VALUE = false - -NULL_VALUE = null +NULL_VALUE = null +EMPTY_VALUE = empty NUMERIC_VALUE = 13 -PI_NUMBER = 3.14 +PI_NUMBER = 3.14 +ZIP_CODE = 007 ``` -`any.php` : +```php +require 'vendor/autoload.php'; -```php -require_once "vendor/autoload.php"; -use \InitPHP\DotENV\DotENV; +use InitPHP\DotENV\DotENV; -DotENV::create('/home/www/.env'); +DotENV::create('/home/www/.env'); // or DotENV::create('/home/www'); -DotENV::get('TRUE_VALE'); // true -DotENV::get('FALSE_VALUE'); // false -DotENV::get('SITE_URL'); // "http://lvh.me" -DotENV::get('PAGE_URL'); // "http://lvh.me/page" -DotENV::get('EMPTY_VALUE'); // "" -DotENV::get('NULL_VALUE'); // NULL -DotENV::get('NUMERIC_VALUE'); // 13 -DotENV::get('PI_NUMBER'); // 3.14 +DotENV::get('SITE_URL'); // "http://lvh.me" +DotENV::get('PAGE_URL'); // "http://lvh.me/page" +DotENV::get('TRUE_VALUE'); // true (bool) +DotENV::get('FALSE_VALUE'); // false (bool) +DotENV::get('NULL_VALUE'); // null +DotENV::get('EMPTY_VALUE'); // "" (string) +DotENV::get('NUMERIC_VALUE'); // 13 (int) +DotENV::get('PI_NUMBER'); // 3.14 (float) +DotENV::get('ZIP_CODE'); // "007" (string — leading zero preserved) DotENV::get('NOT_FOUND', 'hi'); // "hi" -``` -### `.env.php` - -`/home/www/.env.php` : - -```php - 'http://lvh.me', - 'PAGE_URL' => '${SITE_URL}/page', - 'TRUE_VALE' => true, - 'EMPTY_VALUE' => '', - 'FALSE_VALUE' => false, - 'NULL_VALUE' => null, - 'NUMERIC_VALUE' => 13 -]; +env('SITE_URL'); // global helper, same shared state ``` -`any.php` : +Prefer an isolated instance (e.g. for DI or tests)? Use the `Repository` +directly: -```php -require_once "vendor/autoload.php"; -use \InitPHP\DotENV\DotENV; +```php +use InitPHP\DotENV\Repository; -DotENV::create('/home/www/.env.php'); +$env = new Repository(); +$env->create('/home/www/.env'); +$env->get('SITE_URL'); +``` +## File format in brief -DotENV::get('TRUE_VALE'); // true -DotENV::get('FALSE_VALUE'); // false -DotENV::get('SITE_URL'); // "http://lvh.me" -DotENV::get('EMPTY_VALUE'); // "" -DotENV::get('NULL_VALUE'); // NULL -DotENV::get('NUMERIC_VALUE'); // 13 +- `KEY = VALUE` — split on the first `=`; whitespace around the key is trimmed. +- Comments start a line with anything other than a letter/digit/`_`/`-` + (`#`, `;`, `//`); inline comments start at a `#` preceded by whitespace. +- Quote a value (`"..."` or `'...'`) to keep it verbatim, including a leading + `#` such as `#ffffff`. +- `${OTHER}` references are expanded when the value is read. +- `true` / `false` / `null` / `empty` and round-tripping numbers are coerced; + everything else stays a string. -DotENV::get('NOT_FOUND', 'hi'); // "hi" -``` +Full details: [`docs/`](docs/README.md). -### `DotENV::create()` +## API summary -Reads and defines an `.env` or `.env.php` file. +| Call | Returns | Purpose | +| ---- | ------- | ------- | +| `DotENV::create(string $path, bool $debug = true)` | `void` | Load a `.env`/`.env.php` file or a directory containing one. | +| `DotENV::get(string $name, mixed $default = null)` | `mixed` | Read a value (`$_ENV` → `$_SERVER` → `getenv()`). | +| `DotENV::env(string $name, mixed $default = null)` | `mixed` | Alias of `get()`. | +| `DotENV::flush()` | `void` | Unload everything this instance defined. | +| `DotENV::reset()` | `void` | `flush()` and drop the shared instance. | +| `env(string $name, mixed $default = null)` | `mixed` | Global helper for `DotENV::get()`. | -```php -public function create(string $path, bool $debug = true): void; -``` +See the [API reference](docs/api-reference.md) for details. -- `$path` : The path to the file to be uploaded. If you define a directory path, Dotenv will try to search for the `.env` or `.env.php` file itself. -- `$debug` : Defines the exception throwing state. If `false` no exception is thrown. +## Notes -**Note :** If the file is not found, the file is not a `.env`/`.env.php` file, or is unreadable, it throws a `\Exception` variant. +- **Immutability:** values already in `$_ENV` or `$_SERVER` are never + overwritten, so real environment variables win over a committed `.env`. +- **`$debug`:** when `false`, `create()` swallows every error (missing file, + wrong type, unreadable) instead of throwing a + [`DotENVException`](docs/exceptions.md). +- **Security:** keep `.env` files out of the web root, and remember a + `.env.php` file is executed as code — see the + [security notes](docs/security.md). -### `DotENV::get()` +## Upgrading from 2.x -Returns an ENV value. +3.0 is a maintenance-focused major release. The public API (`DotENV::create`, +`get`, `env` and the `env()` helper) is unchanged, but note: -```php -public function get(string $name, mixed $default = null): mixed; -``` +- **PHP 8.0+** is now required (was 5.6+). +- **Numbers are only coerced when it is loss-free.** `007` and `+90555…` now + stay strings instead of becoming `7` and a float. +- Real bug fixes change previously broken results: directory-path loading, + multiple `${VAR}` references on one line, quoted values with spaces around + `=`, and values like `#ffffff` all work now. +- The internal `Lib` class is renamed `Repository`; `Lib` remains as a + deprecated alias. -**Note :** The priority order is as follows; +See the [changelog](CHANGELOG.md) for the full list. -`$_ENV` -> `$_SERVER` -> `getenv()` +## Contributing -### `DotENV::env()` +Bug reports and pull requests are welcome. The CI runs PHP-CS-Fixer, PHPStan +(max level) and PHPUnit across PHP 8.0–8.4; run the same bundle locally with: -It's an alias for the `Dotenv::get()` method. - -```php -public function env(string $name, mixed $default = null): mixed; +```bash +composer ci ``` ## Credits @@ -140,4 +139,4 @@ public function env(string $name, mixed $default = null): mixed; ## License -Copyright © 2022 [MIT License](./LICENSE) +Copyright © 2022 InitPHP — released under the [MIT License](./LICENSE). diff --git a/composer.json b/composer.json index a1ea0d1..4703d24 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,39 @@ { "name": "initphp/dotenv", - "description": "DotENV Library/Package", + "description": "Loads environment variables from a .env or .env.php file, with type coercion and ${VAR} interpolation.", "type": "library", "license": "MIT", + "keywords": [ + "dotenv", + "env", + "environment", + "configuration", + "config", + "getenv", + "12factor", + "initphp" + ], + "authors": [ + { + "name": "Muhammet ŞAFAK", + "email": "info@muhammetsafak.com.tr", + "role": "Developer", + "homepage": "https://www.muhammetsafak.com.tr" + } + ], + "support": { + "issues": "https://github.com/InitPHP/DotENV/issues", + "source": "https://github.com/InitPHP/DotENV", + "docs": "https://github.com/InitPHP/DotENV/tree/main/docs" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^1.12 || ^2.1", + "friendsofphp/php-cs-fixer": "^3.65" + }, "autoload": { "psr-4": { "InitPHP\\DotENV\\": "src/" @@ -11,16 +42,39 @@ "src/helpers.php" ] }, - "authors": [ - { - "name": "Muhammet ŞAFAK", - "email": "info@muhammetsafak.com.tr", - "role": "Developer", - "homepage": "https://www.muhammetsafak.com.tr" + "autoload-dev": { + "psr-4": { + "InitPHP\\DotENV\\Tests\\": "tests/" } - ], + }, + "scripts": { + "test": "phpunit", + "test-coverage": "phpunit --coverage-html build/coverage", + "stan": "phpstan analyse --no-progress", + "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix", + "ci": [ + "@cs-check", + "@stan", + "@test" + ] + }, + "scripts-descriptions": { + "test": "Run the PHPUnit test suite.", + "test-coverage": "Run PHPUnit and produce an HTML coverage report under build/coverage.", + "stan": "Run PHPStan at the level configured in phpstan.neon.dist.", + "cs-check": "Report any PHP-CS-Fixer violations without modifying files.", + "cs-fix": "Apply PHP-CS-Fixer fixes in-place.", + "ci": "Run the full CI bundle locally: cs-check, stan, test." + }, + "config": { + "sort-packages": true + }, "minimum-stability": "stable", - "require": { - "php": ">=5.6" + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-main": "3.0.x-dev" + } } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..40509db --- /dev/null +++ b/docs/README.md @@ -0,0 +1,36 @@ +# InitPHP DotENV — Documentation + +InitPHP DotENV loads environment variables from a `.env` or `.env.php` file +into PHP's environment (`$_ENV`, `$_SERVER` and `getenv()`), with type +coercion and `${VAR}` interpolation on read. + +## Contents + +| Guide | What it covers | +| ----- | -------------- | +| [Getting started](getting-started.md) | Install, the two entry points, your first load. | +| [The `.env` file format](env-file-format.md) | Comments, quoting, inline comments, the `export` prefix. | +| [Using a `.env.php` file](php-env-file.md) | Returning a native PHP array instead of plain text. | +| [Variable interpolation](variable-interpolation.md) | `${VAR}` references, nesting, and circular-reference handling. | +| [Value types](value-types.md) | How `true`/`false`/`null`/`empty`, ints and floats are coerced. | +| [API reference](api-reference.md) | Every public method and the `env()` helper. | +| [Exceptions](exceptions.md) | What is thrown, when, and how to catch it. | +| [Security notes](security.md) | Keeping `.env` files out of reach and the `.env.php` caveat. | + +## At a glance + +```php +require 'vendor/autoload.php'; + +use InitPHP\DotENV\DotENV; + +DotENV::create(__DIR__); // loads __DIR__/.env (or .env.php) + +DotENV::get('APP_ENV', 'local'); // string|int|float|bool|null +env('APP_ENV', 'local'); // the global helper, same thing +``` + +## Requirements + +- PHP 8.0 or newer +- No required extensions beyond the PHP core diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..3a8adf8 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,108 @@ +# API reference + +The package exposes one worker class (`Repository`), a static facade over a +shared instance of it (`DotENV`), and one global helper (`env()`). + +## `Repository` + +`InitPHP\DotENV\Repository` + +### `create()` + +```php +public function create(string $path, bool $debug = true): void +``` + +Reads and defines a `.env` or `.env.php` file. + +- **`$path`** — a path to a `.env`/`.env.php` file, or to a directory that + contains one. When a directory is given, `.env` is tried first, then + `.env.php`. +- **`$debug`** — when `true` (default), problems throw a + [`DotENVException`](exceptions.md); when `false`, problems are ignored and + the method returns without defining anything. + +Values already present in `$_ENV` or `$_SERVER` are not overwritten. + +### `get()` + +```php +public function get(string $name, mixed $default = null): mixed +``` + +Returns an environment value, looked up as `$_ENV` → `$_SERVER` → `getenv()`. +Strings are coerced ([value types](value-types.md)) and `${VAR}` references +are interpolated ([interpolation](variable-interpolation.md)). Returns +`$default` when the name is not defined anywhere. + +### `env()` + +```php +public function env(string $name, mixed $default = null): mixed +``` + +Alias of `get()`. + +### `flush()` + +```php +public function flush(): void +``` + +Removes every value this repository defined (from `$_ENV`, `$_SERVER` and +`putenv()`) and clears the read cache. Pre-existing environment variables are +left untouched. + +## `DotENV` (facade) + +`InitPHP\DotENV\DotENV` + +A static facade that forwards `create()`, `get()`, `env()` and `flush()` to a +single shared `Repository`. It adds two lifecycle helpers: + +### `instance()` + +```php +public static function instance(): Repository +``` + +Returns the shared repository, creating it on first use. + +### `reset()` + +```php +public static function reset(): void +``` + +Flushes the shared repository and discards it, so the next call builds a fresh +one. Useful in tests and long-running workers. + +```php +use InitPHP\DotENV\DotENV; + +DotENV::create(__DIR__); +DotENV::get('APP_ENV'); +DotENV::flush(); // unload, keep the instance +DotENV::reset(); // unload and drop the instance +``` + +## `env()` global helper + +Registered through Composer's `files` autoloader: + +```php +function env(string $name, mixed $default = null): mixed +``` + +Equivalent to `DotENV::get($name, $default)` and shares the same shared +repository. It is only defined if no other `env()` function already exists. + +```php +$appEnv = env('APP_ENV', 'production'); +``` + +## Backwards compatibility: `Lib` + +Before 3.0 the worker class was named `InitPHP\DotENV\Lib`. That name still +resolves — it is registered as an alias of `Repository` — but it is +deprecated. Use `Repository` in new code. diff --git a/docs/env-file-format.md b/docs/env-file-format.md new file mode 100644 index 0000000..5542d4b --- /dev/null +++ b/docs/env-file-format.md @@ -0,0 +1,101 @@ +# The `.env` file format + +A `.env` file is a list of `KEY=VALUE` lines. This page describes exactly how +each line is parsed. + +```dotenv +# This is a comment +APP_NAME = InitPHP +APP_ENV = production + +DB_HOST = 127.0.0.1 +DB_PORT = 3306 +``` + +## Keys + +- A key and its value are split on the **first** `=` on the line, so a value + may itself contain `=`: + + ```dotenv + DSN = mysql:host=localhost;dbname=app + # key: DSN, value: mysql:host=localhost;dbname=app + ``` + +- Surrounding whitespace around the key is trimmed (`FOO = bar` and `FOO=bar` + are equivalent). + +- An optional leading `export ` is stripped, so shell-sourced files work too: + + ```dotenv + export TOKEN = abc123 + # key: TOKEN + ``` + +## Comments + +A line is treated as a **comment** when its first character (after trimming) +is anything other than a letter, a digit, `_`, or `-`. So all of these are +comments: + +```dotenv +# hash comment +; semicolon comment +// slash comment +``` + +Blank lines are ignored. + +### Inline comments + +An inline comment begins at the first `#` that is **preceded by whitespace**: + +```dotenv +URL = http://lvh.me # this part is a comment -> http://lvh.me +``` + +A `#` that is *not* preceded by whitespace is part of the value, so values +such as colours and URL fragments survive: + +```dotenv +COLOR = #ffffff # -> #ffffff +FRAGMENT = page#section # -> page#section +``` + +## Quoting + +Wrap a value in matching single or double quotes to keep it verbatim. The +quotes are removed; everything between them — including a `#` — is preserved. + +```dotenv +GREETING = "hello world" # -> hello world +HASH = "#ffffff" # -> #ffffff +RAW = 'no ${VAR} here' # -> no ${VAR} here (still interpolated on read, see note) +``` + +Quoting also works when there is whitespace around the `=`: + +```dotenv +SITE = "http://lvh.me" # -> http://lvh.me +``` + +An inline comment is still recognised after the closing quote: + +```dotenv +TOKEN = "abc" # secret # -> abc +``` + +> **Note:** quoting changes how the *line* is parsed, not how the *value* is +> resolved later. `${VAR}` interpolation and type coercion happen when you call +> `get()`, regardless of whether the value was quoted in the file. See +> [Variable interpolation](variable-interpolation.md) and +> [Value types](value-types.md). + +## Malformed lines + +A non-comment line with no `=` is ignored rather than producing a junk entry: + +```dotenv +THIS_LINE_IS_IGNORED +VALID = 1 +``` diff --git a/docs/exceptions.md b/docs/exceptions.md new file mode 100644 index 0000000..a353ad4 --- /dev/null +++ b/docs/exceptions.md @@ -0,0 +1,48 @@ +# Exceptions + +The package throws a single exception type: + +`InitPHP\DotENV\Exceptions\DotENVException` + +It extends `\InvalidArgumentException`, so existing +`catch (\InvalidArgumentException $e)` blocks keep working. + +``` +\InvalidArgumentException + └── InitPHP\DotENV\Exceptions\DotENVException +``` + +## When it is thrown + +`create()` throws a `DotENVException` when, with `$debug` left at its default +of `true`: + +| Situation | Message (abridged) | +| --------- | ------------------ | +| A directory was given but contains no `.env`/`.env.php` | *…could not be found in the directory you specified.* | +| The path does not point at an existing file | *The "…" file could not be found.* | +| The file name is not `.env` or `.env.php` | *The file to be loaded must be a ".env" or ".env.php" file.* | +| The file could not be read | *The "…" file could not be read.* | +| A `.env.php` file did not return an array | *The ".env.php" file must return an associative array.* | + +`get()` / `env()` never throw for a missing key — they return the default. + +## Suppressing exceptions + +Pass `false` as the second argument to `create()` to turn every one of the +situations above into a silent no-op: + +```php +use InitPHP\DotENV\DotENV; +use InitPHP\DotENV\Exceptions\DotENVException; + +// Throwing style +try { + DotENV::create('/etc/app/.env'); +} catch (DotENVException $e) { + // log and fall back to defaults +} + +// Best-effort style — load it if it's there, ignore it if it isn't +DotENV::create('/etc/app/.env', false); +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..bf0db29 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,84 @@ +# Getting started + +## Install + +```bash +composer require initphp/dotenv +``` + +The package needs PHP 8.0 or newer and no extensions beyond the PHP core. + +## The two entry points + +### 1. The `DotENV` static facade + +The quickest way in. It keeps a single shared repository behind the scenes, +so every call sees the same loaded values. + +```php +use InitPHP\DotENV\DotENV; + +DotENV::create('/path/to/project/.env'); + +$debug = DotENV::get('APP_DEBUG', false); +``` + +### 2. The `Repository` object + +Use this when you want an isolated instance — for dependency injection, for +loading more than one file set side by side, or for tests. + +```php +use InitPHP\DotENV\Repository; + +$env = new Repository(); +$env->create('/path/to/project/.env'); + +$debug = $env->get('APP_DEBUG', false); +``` + +The facade is simply a thin static wrapper around one shared `Repository`. + +## Loading a file + +`create()` accepts either a file path or a directory path: + +```php +DotENV::create('/path/to/project/.env'); // explicit file +DotENV::create('/path/to/project/.env.php'); // explicit PHP file +DotENV::create('/path/to/project'); // directory: finds .env, then .env.php +``` + +When you pass a directory, the repository looks for a `.env` file first and a +`.env.php` file second. + +## Reading values + +```php +DotENV::get('NAME'); // value, or null if undefined +DotENV::get('NAME', 'fallback'); // value, or 'fallback' if undefined +DotENV::env('NAME'); // alias of get() +env('NAME', 'fallback'); // global helper, registered via Composer +``` + +Resolution order is `$_ENV` → `$_SERVER` → `getenv()`. The first store that +defines the name wins. See [Value types](value-types.md) for how the raw +string becomes a `bool`, `int`, `float`, `null` or `string`. + +## Immutability + +Values already present in `$_ENV` or `$_SERVER` are **never** overwritten by +`create()`. This lets real environment variables (set by the OS, the web +server, or a container) take precedence over a committed `.env` file. + +## Reloading (tests and workers) + +`create()` is additive and immutable, so to start over — typically in a test +or a long-running worker — drop what was loaded first: + +```php +DotENV::flush(); // removes only what this repository defined +DotENV::reset(); // flush() + discard the shared instance +``` + +Pre-existing environment variables are left untouched by both. diff --git a/docs/php-env-file.md b/docs/php-env-file.md new file mode 100644 index 0000000..11a7dae --- /dev/null +++ b/docs/php-env-file.md @@ -0,0 +1,65 @@ +# Using a `.env.php` file + +Instead of a plain-text `.env` file you can use a `.env.php` file that +**returns an associative array**. This is handy when you want native PHP +types (real booleans, integers, `null`) without relying on string coercion, +or when you want to compute values. + +```php + 'http://lvh.me', + 'PAGE_URL' => '${SITE_URL}/page', + 'TRUE_VALUE' => true, + 'FALSE_VALUE' => false, + 'NULL_VALUE' => null, + 'NUMERIC_VALUE' => 13, +]; +``` + +```php +use InitPHP\DotENV\DotENV; + +DotENV::create('/path/to/project/.env.php'); + +DotENV::get('SITE_URL'); // "http://lvh.me" +DotENV::get('PAGE_URL'); // "http://lvh.me/page" (interpolated on read) +DotENV::get('TRUE_VALUE'); // true (bool, not coerced from a string) +DotENV::get('NULL_VALUE'); // null +DotENV::get('NUMERIC_VALUE'); // 13 (int) +``` + +You can also point `create()` at the directory and let it find the file: + +```php +DotENV::create('/path/to/project'); // uses .env if present, else .env.php +``` + +## How values are treated + +- **Strings** are stored and behave exactly like `.env` values: they are + pushed to `putenv()` and resolved through coercion and `${VAR}` + interpolation when you read them. +- **Non-strings** (`bool`, `int`, `float`, `null`, arrays, objects) are stored + as-is in `$_ENV` / `$_SERVER` and returned unchanged by `get()`. They are + *not* pushed to `putenv()`, because `putenv()` only accepts strings. +- Array keys are cast to strings. + +## Validation + +If the file does not return an array, a +[`DotENVException`](exceptions.md) is thrown (unless you disabled exceptions +with the second `create()` argument): + +```php +DotENV::create('/path/to/bad.env.php'); // throws if it returns a non-array +DotENV::create('/path/to/bad.env.php', false); // silently does nothing instead +``` + +## A word of caution + +A `.env.php` file is executed with `require`. Treat it as code, not data: +never load a `.env.php` file from an untrusted source. See the +[security notes](security.md). diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..c81ec0d --- /dev/null +++ b/docs/security.md @@ -0,0 +1,46 @@ +# Security notes + +A `.env` file usually holds secrets — database passwords, API keys, signing +keys. Treat it accordingly. + +## Keep `.env` files out of the web root + +If a `.env` file sits under a publicly served directory, a misconfiguration +can serve it as plain text and leak every secret in it. + +- **Best:** keep the file in a directory that the web server never serves + (e.g. one level above the document root) and point `create()` at it. +- **Otherwise:** block access explicitly. With Apache: + + ```apacheconf + + Require all denied + + ``` + + With nginx: + + ```nginx + location ~ /\.env { + deny all; + } + ``` + +## Never commit real secrets + +Commit a `.env.example` with empty or dummy values and add `.env` to your +`.gitignore`. Load the example only as documentation, not at runtime. + +## `.env.php` runs code + +A [`.env.php`](php-env-file.md) file is loaded with `require`, so it is +executed as PHP. Only ever load a `.env.php` file you control. Loading one +from an untrusted or user-writable location is equivalent to running arbitrary +code. + +## Environment variables take precedence + +`create()` does not overwrite names that already exist in `$_ENV` or +`$_SERVER`. In production you can therefore set the real secrets as actual +environment variables (via your process manager, container, or platform) and +keep the `.env` file for local development only — the real values win. diff --git a/docs/value-types.md b/docs/value-types.md new file mode 100644 index 0000000..a37781a --- /dev/null +++ b/docs/value-types.md @@ -0,0 +1,80 @@ +# Value types + +`.env` files only store text, but reading a value back gives you a typed PHP +value. Coercion happens in `get()` (and therefore `env()`), once per name. + +## Keywords + +These literal values (case-insensitive) are coerced to PHP types: + +| In the file | `get()` returns | +| ------------------ | --------------- | +| `true` | `true` (bool) | +| `false` | `false` (bool) | +| `null` | `null` | +| `empty` | `''` (string) | +| *(nothing)* `KEY=` | `''` (string) | + +```dotenv +DEBUG = true +CACHE = false +TOKEN = null +NOTE = empty +BLANK = +``` + +```php +DotENV::get('DEBUG'); // true +DotENV::get('CACHE'); // false +DotENV::get('TOKEN'); // null +DotENV::get('NOTE'); // "" +DotENV::get('BLANK'); // "" +``` + +## Numbers + +A value is coerced to `int` or `float` **only when the conversion round-trips +exactly** — that is, when turning the number back into a string reproduces the +original text. This keeps numeric-looking identifiers intact. + +```dotenv +PORT = 8080 # int 8080 +PI = 3.14 # float 3.14 +NEG = -42 # int -42 +``` + +```php +DotENV::get('PORT'); // 8080 (int) +DotENV::get('PI'); // 3.14 (float) +DotENV::get('NEG'); // -42 (int) +``` + +Values that look numeric but would lose information stay **strings**: + +```dotenv +ZIP = 007 # string "007" (leading zero) +PHONE = +905551112233 # string "+905..." (leading plus) +BIG = 99999999999999999999 # string (beyond PHP_INT_MAX) +SCI = 1e3 # string "1e3" (not "1000") +``` + +```php +DotENV::get('ZIP'); // "007" — not 7 +DotENV::get('PHONE'); // "+905551112233" +``` + +> This is deliberate. Earlier versions used `intval()`/`floatval()` +> unconditionally, which turned `007` into `7` and phone numbers into floats. +> See the [changelog](../CHANGELOG.md). + +## Everything else + +Any value that is not a keyword and not a round-tripping number is returned as +a string, after `${VAR}` interpolation. See +[Variable interpolation](variable-interpolation.md). + +## `.env.php` values + +Values coming from a [`.env.php`](php-env-file.md) file that are already +non-strings (real booleans, ints, `null`, arrays) are returned unchanged — +no coercion is applied to them. diff --git a/docs/variable-interpolation.md b/docs/variable-interpolation.md new file mode 100644 index 0000000..cb3560b --- /dev/null +++ b/docs/variable-interpolation.md @@ -0,0 +1,80 @@ +# Variable interpolation + +A value may reference another variable with `${NAME}`. References are resolved +**when you read the value** with `get()`, not when the file is loaded. + +```dotenv +SITE_URL = http://lvh.me +PAGE_URL = ${SITE_URL}/page +``` + +```php +DotENV::get('PAGE_URL'); // "http://lvh.me/page" +``` + +## Multiple references on one line + +Any number of references may appear in a single value: + +```dotenv +HOST = localhost +PORT = 8080 +ADDR = ${HOST}:${PORT} +``` + +```php +DotENV::get('ADDR'); // "localhost:8080" +``` + +## Nested references + +A referenced value may itself contain references; they are resolved +recursively: + +```dotenv +A = root +B = ${A}/b +C = ${B}/c +``` + +```php +DotENV::get('C'); // "root/b/c" +``` + +## Resolution source + +`${NAME}` is resolved with the same lookup that `get()` uses +(`$_ENV` → `$_SERVER` → `getenv()`), so a reference can point at a real +environment variable, not just another line in the same file. + +A non-scalar referenced value (an array or object loaded from a `.env.php` +file) resolves to an empty string inside an interpolation. + +## Missing references + +A reference to an undefined name resolves to an empty string: + +```dotenv +VALUE = ${DOES_NOT_EXIST}suffix +``` + +```php +DotENV::get('VALUE'); // "suffix" +``` + +## Circular references + +A self-reference or a cycle resolves to an empty string instead of recursing +forever: + +```dotenv +A = ${A} +B = ${C} +C = ${B} +``` + +```php +DotENV::get('A'); // "" +DotENV::get('B'); // "" +DotENV::get('C'); // "" +``` diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..3957b75 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +parameters: + level: max + paths: + - src + - tests + tmpDir: build/phpstan + excludePaths: + # BC shim: registers the legacy `Lib` class name via class_alias(). + # PHPStan cannot model a class created at runtime that has no + # declaration of its own, which is the whole point of the file. + - src/Lib.php + # Exercises that runtime alias; the symbol does not exist statically. + - tests/LibBackwardsCompatibilityTest.php + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..681c656 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + tests + + + + + src + + + src/helpers.php + src/Lib.php + + + diff --git a/src/DotENV.php b/src/DotENV.php index 7267994..32caa60 100644 --- a/src/DotENV.php +++ b/src/DotENV.php @@ -1,48 +1,92 @@ * @copyright Copyright © 2022 InitPHP - * @license https://initphp.github.io/license.txt MIT - * @version 2.0 + * @license https://github.com/InitPHP/DotENV/blob/main/LICENSE MIT * @link https://www.muhammetsafak.com.tr */ +declare(strict_types=1); + namespace InitPHP\DotENV; /** - * @method static void create(string $path, bool $debug = true) + * Static facade over a shared {@see Repository} instance. + * + * ```php + * DotENV::create('/path/to/project'); + * $url = DotENV::get('SITE_URL'); + * ``` + * + * @method static void create(string $path, bool $debug = true) * @method static mixed get(string $name, mixed $default = null) * @method static mixed env(string $name, mixed $default = null) + * @method static void flush() + * + * @see Repository */ -class DotENV +final class DotENV { - - /** @var Lib */ - protected static $instance; + /** @var Repository|null The shared repository instance. */ + private static ?Repository $instance = null; /** - * @return Lib + * Returns the shared repository, creating it on first use. + * + * @return Repository */ - protected static function getInstance() + public static function instance(): Repository { - if(!isset(self::$instance)){ - self::$instance = new Lib(); + if (self::$instance === null) { + self::$instance = new Repository(); } + return self::$instance; } - public function __call($name, $arguments) + /** + * Flushes and drops the shared repository instance. + * + * After this call the next facade call builds a fresh repository. Useful + * in tests and long-running workers. Pre-existing environment variables + * are left untouched. + * + * @return void + */ + public static function reset(): void { - return self::getInstance()->{$name}(...$arguments); + if (self::$instance !== null) { + self::$instance->flush(); + } + self::$instance = null; } - public static function __callStatic($name, $arguments) + /** + * Forwards instance calls to the shared repository. + * + * @param string $name + * @param array $arguments + * @return mixed + */ + public function __call(string $name, array $arguments): mixed { - return self::getInstance()->{$name}(...$arguments); + return self::instance()->{$name}(...$arguments); } + /** + * Forwards static calls to the shared repository. + * + * @param string $name + * @param array $arguments + * @return mixed + */ + public static function __callStatic(string $name, array $arguments): mixed + { + return self::instance()->{$name}(...$arguments); + } } diff --git a/src/Exceptions/DotENVException.php b/src/Exceptions/DotENVException.php index e1d2eda..d0ca0b8 100644 --- a/src/Exceptions/DotENVException.php +++ b/src/Exceptions/DotENVException.php @@ -1,18 +1,28 @@ - - * @copyright Copyright © 2022 InitPHP - * @license https://initphp.github.io/license.txt MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -namespace InitPHP\DotENV\Exceptions; - -class DotENVException extends \InvalidArgumentException -{ -} + + * @copyright Copyright © 2022 InitPHP + * @license https://github.com/InitPHP/DotENV/blob/main/LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\DotENV\Exceptions; + +use InvalidArgumentException; + +/** + * Raised when a `.env` / `.env.php` file cannot be located, read, or parsed. + * + * Extends {@see InvalidArgumentException} for backwards compatibility, so + * existing `catch (\InvalidArgumentException $e)` blocks keep working. + */ +class DotENVException extends InvalidArgumentException +{ +} diff --git a/src/Lib.php b/src/Lib.php index 3655d0e..6f6b615 100644 --- a/src/Lib.php +++ b/src/Lib.php @@ -1,256 +1,30 @@ - - * @copyright Copyright © 2022 InitPHP - * @license https://initphp.github.io/license.txt MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -namespace InitPHP\DotENV; - -use InitPHP\DotENV\Exceptions\DotENVException; - -use const DIRECTORY_SEPARATOR; -use const FILE_IGNORE_NEW_LINES; -use const FILE_SKIP_EMPTY_LINES; - -use function is_string; -use function is_array; -use function is_numeric; -use function intval; -use function floatval; -use function strtolower; -use function preg_match; -use function preg_replace_callback; -use function substr; -use function putenv; -use function getenv; -use function sprintf; -use function trim; -use function rtrim; -use function explode; -use function file; -use function is_dir; -use function basename; -use function is_file; -use function strlen; - -final class Lib -{ - - /** @var array */ - protected $ENV = []; - - /** - * Bir .env dosyasını işler. - * - * @param string $path - * @param bool $debug

Eğer dosya bulunamaz ya da okunamaz ise istisna fırlatma davranışını değiştirir.

- * @return void - * @throws DotENVException - */ - public function create($path, $debug = true) - { - $this->createImmutable($path, $debug); - } - - /** - * Bir ENV değerini döndürür. - * - * @param string $name - * @param mixed $default - * @return string|bool|null|mixed - */ - public function get($name, $default = null) - { - if(isset($this->ENV[$name])){ - return $this->ENV[$name]; - } - if(isset($_ENV[$name])){ - return $this->ENV[$name] = $this->convert($_ENV[$name]); - } - if(isset($_SERVER[$name])){ - return $this->ENV[$name] = $this->convert($_SERVER[$name]); - } - if(($env = getenv($name)) !== FALSE){ - return $this->ENV[$name] = $this->convert($env); - } - return $default; - } - - /** - * Bir ENV değerini döndürür. - * - * @see get() - * @param string $name - * @param mixed $default - * @return string|bool|null|mixed - */ - public function env($name, $default = null) - { - return $this->get($name, $default); - } - - /** - * @param string $path - * @param bool $debug - * @return void - */ - private function createImmutable($path, $debug = true) - { - if(is_dir($path)){ - if(($path = $this->getDirFilePath($path)) === null){ - if($debug !== FALSE){ - throw new DotENVException('The file ".env" or ".env.php" could not be found in the directory you specified.'); - } - return; - } - } - if(!is_file($path)){ - if($debug !== FALSE){ - throw new DotENVException('The ' . $path . ' file could not be found.'); - } - return; - } - $basename = basename($path); - if($basename != '.env' && $basename != '.env.php'){ - if($debug !== FALSE){ - throw new DotENVException('The file to be uploaded must be ".env" or ".env.php" file.'); - } - return; - } - if($basename == '.env'){ - $immutable = []; - if(($read = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)) === FALSE){ - if($debug !== FALSE){ - throw new DotENVException('The "' . $path . '" file could not be read.'); - } - return; - } - foreach ($read as $line) { - $line = trim($line); - if($line == '' || $this->isCommentLine($line)){ - continue; - } - list($key, $value) = explode('=', $line, 2); - - $key = trim((string)$key); - - if (preg_match('/^"(.*)"/i', $value, $matches) || preg_match("/^'(.*)'/i", $value, $matches)) { - $value = $matches[1]; - } else { - preg_match('/[ \t]*+(?:#.*)?$/i', $value, $matches); - if (!empty($matches[0])) { - $value = substr($value, 0, (0 - strlen($matches[0]))); - } - $value = trim($value); - } - $immutable[$key] = $value; - } - }else{ - $immutable = $this->phpRequired($path); - if(!is_array($immutable)){ - if($debug !== FALSE){ - throw new DotENVException('The file ".env.php" should return an associative array.'); - } - return; - } - } - $this->createArrayImmutable($immutable); - } - - /** - * @param array $assoc - * @return void - */ - private function createArrayImmutable($assoc) - { - foreach ($assoc as $key => $value) { - if(!isset($_SERVER[$key]) && !isset($_ENV[$key])){ - if(is_string($value)){ - putenv(sprintf('%s=%s', $key, $value)); - } - $_ENV[$key] = $value; - $_SERVER[$key] = $value; - } - } - } - - - /** - * @param $line - * @return bool - */ - private function isCommentLine($line) - { - $firstChar = substr($line, 0, 1); - return !((bool)preg_match('/^[\w-]+$/u', $firstChar)); - } - - private function convert($data) - { - if(!is_string($data)){ - return $data; - } - switch (strtolower($data)) { - case 'true': - return true; - case 'false': - return false; - case 'empty': - case '': - return ''; - case 'null': - return null; - } - $data = $this->nestedVariables($data); - if(is_numeric($data)){ - if((bool)preg_match('/^-?([0-9]+)$/u', $data)){ - return intval($data); - } - return floatval($data); - } - return $data; - } - - private function nestedVariables($data) - { - return preg_replace_callback('/\${(.+)}/', function ($env) { - $env = trim($env[1], " \t\n\r\0\x0B\"'"); - return (string)$this->get($env); - }, $data); - } - - /** - * @param string $dir - * @return string|null - */ - private function getDirFilePath($dir) - { - $path = rtrim($dir . '\\/') . DIRECTORY_SEPARATOR; - if(is_file(($path . '.env'))){ - $path .= '.env'; - return $path; - } - if(is_file($path . '.env.php')){ - $path .= '.env.php'; - return $path; - } - return null; - } - - /** - * @param string $file - * @return array - */ - private function phpRequired($file) - { - return require $file; - } - -} + + * @copyright Copyright © 2022 InitPHP + * @license https://github.com/InitPHP/DotENV/blob/main/LICENSE MIT + * @link https://www.muhammetsafak.com.tr + * + * @deprecated 3.0 Use {@see \InitPHP\DotENV\Repository} instead. + */ + +declare(strict_types=1); + +namespace InitPHP\DotENV; + +use function class_alias; +use function class_exists; + +if (!class_exists(Lib::class, false)) { + class_alias(Repository::class, Lib::class); +} diff --git a/src/Repository.php b/src/Repository.php new file mode 100644 index 0000000..25802c1 --- /dev/null +++ b/src/Repository.php @@ -0,0 +1,487 @@ + + * @copyright Copyright © 2022 InitPHP + * @license https://github.com/InitPHP/DotENV/blob/main/LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +namespace InitPHP\DotENV; + +use InitPHP\DotENV\Exceptions\DotENVException; + +use function array_pop; +use function basename; +use function explode; +use function file; +use function is_dir; +use function is_file; +use function is_numeric; +use function is_readable; +use function ltrim; +use function preg_match; +use function preg_replace; +use function preg_replace_callback; +use function putenv; +use function rtrim; +use function str_contains; +use function strncmp; +use function strtolower; +use function substr; +use function trim; + +use const DIRECTORY_SEPARATOR; +use const FILE_IGNORE_NEW_LINES; +use const FILE_SKIP_EMPTY_LINES; + +/** + * Loads `.env` / `.env.php` files into the process environment and reads + * values back out with type coercion and `${VAR}` interpolation. + * + * This is the concrete worker behind the {@see DotENV} facade. It can also + * be used standalone: + * + * ```php + * $env = new Repository(); + * $env->create('/path/to/project'); + * $debug = $env->get('APP_DEBUG', false); + * ``` + */ +final class Repository +{ + /** + * Accepted file names, in lookup priority order, when a directory path + * is given to {@see create()}. + */ + private const FILENAMES = ['.env', '.env.php']; + + /** + * Resolved values, keyed by name. Acts as a read cache so a value is + * coerced/interpolated at most once per name. + * + * @var array + */ + private array $cache = []; + + /** + * Names this instance has written into `$_ENV` / `$_SERVER` / `putenv()`. + * Tracked so {@see flush()} can remove exactly what it added without + * touching pre-existing environment variables. + * + * @var array + */ + private array $loaded = []; + + /** + * Names currently being interpolated. Used to break circular `${VAR}` + * references (e.g. `A=${B}`, `B=${A}`) instead of recursing forever. + * + * @var list + */ + private array $resolving = []; + + /** + * Reads and defines a `.env` or `.env.php` file. + * + * If `$path` is a directory, the repository looks for a `.env` file and + * then a `.env.php` file inside it. Values that already exist in `$_ENV` + * or `$_SERVER` are never overwritten. + * + * @param string $path Path to a `.env`/`.env.php` file, or to a + * directory that contains one. + * @param bool $debug When true (default), problems throw a + * {@see DotENVException}; when false they are + * silently ignored and the method returns. + * @return void + * @throws DotENVException + */ + public function create(string $path, bool $debug = true): void + { + $file = $this->resolvePath($path, $debug); + if ($file === null) { + return; + } + + $values = $this->parseFile($file, $debug); + if ($values === null) { + return; + } + + $this->store($values); + } + + /** + * Returns an environment value. + * + * Resolution order is `$_ENV` → `$_SERVER` → `getenv()`. String values + * are coerced (`"true"`/`"false"`/`"null"`/`"empty"`, integers and + * floats) and any `${VAR}` references are interpolated. + * + * @param string $name The environment variable name. + * @param mixed $default Returned when the variable is not defined. + * @return mixed + */ + public function get(string $name, mixed $default = null): mixed + { + if (\array_key_exists($name, $this->cache)) { + return $this->cache[$name]; + } + if (\array_key_exists($name, $_ENV)) { + return $this->cache[$name] = $this->convert($_ENV[$name]); + } + if (\array_key_exists($name, $_SERVER)) { + return $this->cache[$name] = $this->convert($_SERVER[$name]); + } + + $env = getenv($name); + if ($env !== false) { + return $this->cache[$name] = $this->convert($env); + } + + return $default; + } + + /** + * Alias of {@see get()}. + * + * @param string $name The environment variable name. + * @param mixed $default Returned when the variable is not defined. + * @return mixed + */ + public function env(string $name, mixed $default = null): mixed + { + return $this->get($name, $default); + } + + /** + * Removes every value this instance defined and clears the read cache. + * + * Pre-existing environment variables (those present before {@see create()} + * ran) are left untouched. Primarily a seam for tests and long-running + * workers that need to reload configuration. + * + * @return void + */ + public function flush(): void + { + foreach ($this->loaded as $key) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + } + $this->loaded = []; + $this->cache = []; + $this->resolving = []; + } + + /** + * Turns a path into a concrete, accepted file path, or null. + * + * @param string $path + * @param bool $debug + * @return string|null The resolved file path, or null when something is + * wrong and `$debug` is false. + * @throws DotENVException + */ + private function resolvePath(string $path, bool $debug): ?string + { + if (is_dir($path)) { + $found = $this->locateInDirectory($path); + if ($found === null) { + if ($debug) { + throw new DotENVException('The file ".env" or ".env.php" could not be found in the directory you specified.'); + } + return null; + } + $path = $found; + } + + if (!is_file($path)) { + if ($debug) { + throw new DotENVException(\sprintf('The "%s" file could not be found.', $path)); + } + return null; + } + + if (!\in_array(basename($path), self::FILENAMES, true)) { + if ($debug) { + throw new DotENVException('The file to be loaded must be a ".env" or ".env.php" file.'); + } + return null; + } + + return $path; + } + + /** + * Finds the first accepted env file inside a directory. + * + * @param string $directory + * @return string|null + */ + private function locateInDirectory(string $directory): ?string + { + $base = rtrim($directory, '\\/') . DIRECTORY_SEPARATOR; + foreach (self::FILENAMES as $filename) { + if (is_file($base . $filename)) { + return $base . $filename; + } + } + + return null; + } + + /** + * Reads a resolved file into an associative array of raw values. + * + * @param string $file + * @param bool $debug + * @return array|null + * @throws DotENVException + */ + private function parseFile(string $file, bool $debug): ?array + { + if (basename($file) === '.env.php') { + $values = $this->requirePhpFile($file); + if (!\is_array($values)) { + if ($debug) { + throw new DotENVException('The ".env.php" file must return an associative array.'); + } + return null; + } + + $normalised = []; + foreach ($values as $key => $value) { + $normalised[(string) $key] = $value; + } + + return $normalised; + } + + $lines = is_readable($file) + ? file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + : false; + if ($lines === false) { + if ($debug) { + throw new DotENVException(\sprintf('The "%s" file could not be read.', $file)); + } + return null; + } + + return $this->parseLines($lines); + } + + /** + * Parses the lines of a `.env` file into a key/value map. + * + * @param array $lines + * @return array + */ + private function parseLines(array $lines): array + { + $values = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $this->isComment($line)) { + continue; + } + // A non-comment line with no '=' is malformed; skip it rather + // than defining a key with an empty/null value. + if (!str_contains($line, '=')) { + continue; + } + + [$key, $value] = explode('=', $line, 2); + $key = $this->normaliseKey($key); + // Defensive: the comment and `=` checks above make an empty key + // unreachable today, but guard anyway so a future grammar change + // can never define a blank-named variable. + if ($key === '') { + continue; + } + + $values[$key] = $this->normaliseValue($value); + } + + return $values; + } + + /** + * Decides whether a (already trimmed, non-empty) line is a comment. + * + * A line is treated as a comment unless it begins with a word character + * (`A-Z`, `a-z`, `0-9`, `_`) or a hyphen. This makes `#`, `;`, `//` and + * similar prefixes comments. + * + * @param string $line + * @return bool + */ + private function isComment(string $line): bool + { + return preg_match('/^[\w-]/u', $line) !== 1; + } + + /** + * Trims a key and strips an optional leading `export ` shell prefix. + * + * @param string $key + * @return string + */ + private function normaliseKey(string $key): string + { + $key = trim($key); + if (strncmp($key, 'export ', 7) === 0) { + $key = ltrim(substr($key, 7)); + } + + return $key; + } + + /** + * Normalises a raw value: trims it, strips matching surrounding quotes, + * and removes a trailing inline comment. + * + * A quoted value keeps its contents verbatim (including any `#`). For an + * unquoted value, an inline comment starts at the first `#` that is + * preceded by whitespace, so a value such as `#ffffff` is preserved. + * + * @param string $value + * @return string + */ + private function normaliseValue(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + if (($value[0] === '"' || $value[0] === "'") + && preg_match('/^(["\'])(.*)\1\s*(?:#.*)?$/s', $value, $matches) === 1) { + return $matches[2]; + } + + $stripped = preg_replace('/\s+#.*$/s', '', $value); + + return rtrim($stripped ?? $value); + } + + /** + * Writes parsed values into the environment without overwriting any name + * that already exists in `$_ENV` or `$_SERVER`. + * + * @param array $values + * @return void + */ + private function store(array $values): void + { + foreach ($values as $key => $value) { + if (\array_key_exists($key, $_ENV) || \array_key_exists($key, $_SERVER)) { + continue; + } + + if (\is_string($value)) { + putenv(\sprintf('%s=%s', $key, $value)); + } + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + $this->loaded[$key] = $key; + } + } + + /** + * Coerces a raw value to its scalar type and interpolates `${VAR}` + * references. Non-string values are returned unchanged. + * + * @param mixed $data + * @return mixed + */ + private function convert(mixed $data): mixed + { + if (!\is_string($data)) { + return $data; + } + + switch (strtolower($data)) { + case 'true': + return true; + case 'false': + return false; + case 'null': + return null; + case 'empty': + case '': + return ''; + } + + return $this->toScalar($this->interpolate($data)); + } + + /** + * Coerces a numeric string to int or float, but only when the conversion + * round-trips exactly. This preserves values such as `007`, `+90555…`, + * `1e3` and integers beyond `PHP_INT_MAX` as strings instead of silently + * mangling them. + * + * @param string $data + * @return int|float|string + */ + private function toScalar(string $data): int|float|string + { + if (!is_numeric($data)) { + return $data; + } + if ((string) (int) $data === $data) { + return (int) $data; + } + if ((string) (float) $data === $data) { + return (float) $data; + } + + return $data; + } + + /** + * Replaces every `${VAR}` reference with the value of `VAR`, guarding + * against circular references (which resolve to an empty string). + * + * @param string $data + * @return string + */ + private function interpolate(string $data): string + { + $result = preg_replace_callback('/\${([^}]+)}/', function (array $match): string { + $name = trim($match[1], " \t\n\r\0\x0B\"'"); + if ($name === '' || \in_array($name, $this->resolving, true)) { + return ''; + } + + $this->resolving[] = $name; + try { + $value = $this->get($name); + } finally { + array_pop($this->resolving); + } + + return \is_scalar($value) ? (string) $value : ''; + }, $data); + + return $result ?? $data; + } + + /** + * Includes a `.env.php` file and returns whatever it produces. + * + * @param string $file + * @return mixed + */ + private function requirePhpFile(string $file): mixed + { + return require $file; + } +} diff --git a/src/helpers.php b/src/helpers.php index 186c0b9..a35f8be 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -1,24 +1,30 @@ - - * @copyright Copyright © 2022 InitPHP - * @license https://initphp.github.io/license.txt MIT - * @version 2.0 - * @link https://www.muhammetsafak.com.tr - */ - -if (!function_exists('env')) { - /** - * @param string $name - * @param mixed $default - * @return mixed - */ - function env($name, $default = null) - { - return \InitPHP\DotENV\DotENV::get($name, $default); - } -} + + * @copyright Copyright © 2022 InitPHP + * @license https://github.com/InitPHP/DotENV/blob/main/LICENSE MIT + * @link https://www.muhammetsafak.com.tr + */ + +declare(strict_types=1); + +use InitPHP\DotENV\DotENV; + +if (!function_exists('env')) { + /** + * Returns an environment value from the shared DotENV repository. + * + * @param string $name The environment variable name. + * @param mixed $default Returned when the variable is not defined. + * @return mixed + */ + function env(string $name, mixed $default = null): mixed + { + return DotENV::get($name, $default); + } +} diff --git a/tests/CreateTest.php b/tests/CreateTest.php new file mode 100644 index 0000000..8c5cecf --- /dev/null +++ b/tests/CreateTest.php @@ -0,0 +1,129 @@ +writeFile("APP_NAME=DotENV\n"); + $repo = new Repository(); + $repo->create($file); + + self::assertSame('DotENV', $repo->get('APP_NAME')); + } + + public function testLoadsEnvFileByDirectoryPath(): void + { + // Regression: directory-path resolution used to be broken by a + // `rtrim($dir . '\\/')` typo and always threw. + $dir = $this->writeDir("APP_ENV=production\n"); + $repo = new Repository(); + $repo->create($dir); + + self::assertSame('production', $repo->get('APP_ENV')); + } + + public function testLoadsEnvFileByDirectoryPathWithTrailingSlash(): void + { + $dir = $this->writeDir("WITH_SLASH=yes\n"); + $repo = new Repository(); + $repo->create($dir . '/'); + + self::assertSame('yes', $repo->get('WITH_SLASH')); + } + + public function testPrefersDotEnvOverDotEnvPhpInTheSameDirectory(): void + { + $dir = $this->makeDir(); + $this->writeInto($dir, '.env', "SOURCE=env\n"); + $this->writeInto($dir, '.env.php', " 'php'];"); + + $repo = new Repository(); + $repo->create($dir); + + self::assertSame('env', $repo->get('SOURCE')); + } + + public function testMissingFileThrowsByDefault(): void + { + $this->expectException(DotENVException::class); + $this->expectExceptionMessage('could not be found'); + + (new Repository())->create('/no/such/path/.env'); + } + + public function testMissingFileIsSilentWhenDebugDisabled(): void + { + (new Repository())->create('/no/such/path/.env', false); + + self::assertNull((new Repository())->get('ANYTHING_AT_ALL')); + } + + public function testDirectoryWithoutEnvFileThrows(): void + { + $this->expectException(DotENVException::class); + $this->expectExceptionMessage('could not be found in the directory'); + + (new Repository())->create($this->makeDir()); + } + + public function testRejectsFileWithUnacceptedName(): void + { + $path = $this->writeFile("X=1\n", 'config.txt'); + + $this->expectException(DotENVException::class); + $this->expectExceptionMessage('must be a ".env" or ".env.php"'); + + (new Repository())->create($path); + } + + public function testRejectsFileWithUnacceptedNameSilentlyWhenDebugDisabled(): void + { + $path = $this->writeFile("X=1\n", 'config.txt'); + (new Repository())->create($path, false); + + self::assertNull((new Repository())->get('X')); + } + + public function testDirectoryWithoutEnvFileIsSilentWhenDebugDisabled(): void + { + (new Repository())->create($this->makeDir(), false); + + self::assertNull((new Repository())->get('NOTHING_HERE')); + } + + public function testUnreadableFileThrows(): void + { + $file = $this->makeUnreadable("UNREADABLE=1\n"); + + $this->expectException(DotENVException::class); + $this->expectExceptionMessage('could not be read'); + + (new Repository())->create($file); + } + + public function testUnreadableFileIsSilentWhenDebugDisabled(): void + { + $file = $this->makeUnreadable("UNREADABLE=1\n"); + (new Repository())->create($file, false); + + self::assertNull((new Repository())->get('UNREADABLE')); + } + + private function makeUnreadable(string $contents): string + { + $file = $this->writeFile($contents); + chmod($file, 0000); + if (is_readable($file)) { + self::markTestSkipped('Filesystem does not enforce read permissions here (running as root?).'); + } + + return $file; + } +} diff --git a/tests/DotEnvTestCase.php b/tests/DotEnvTestCase.php new file mode 100644 index 0000000..213093e --- /dev/null +++ b/tests/DotEnvTestCase.php @@ -0,0 +1,147 @@ + */ + private array $envBackup = []; + + /** @var array */ + private array $serverBackup = []; + + /** @var list */ + private array $tempDirs = []; + + /** @var list */ + private array $putenvKeys = []; + + protected function setUp(): void + { + parent::setUp(); + $this->envBackup = $_ENV; + $this->serverBackup = $_SERVER; + } + + protected function tearDown(): void + { + DotENV::reset(); + + // Drop any putenv()/$_ENV keys the test introduced so they do not + // leak into other tests through getenv(). + foreach (array_keys($_ENV) as $key) { + if (!\array_key_exists($key, $this->envBackup)) { + putenv((string) $key); + } + } + + foreach ($this->putenvKeys as $key) { + putenv($key); + } + $this->putenvKeys = []; + + $_ENV = $this->envBackup; + $_SERVER = $this->serverBackup; + + foreach ($this->tempDirs as $dir) { + $this->deleteTree($dir); + } + $this->tempDirs = []; + + parent::tearDown(); + } + + /** + * Sets an environment variable via putenv() and records it for removal + * during tear-down. + */ + protected function putenvValue(string $name, string $value): void + { + putenv($name . '=' . $value); + $this->putenvKeys[] = $name; + } + + /** + * Writes a throwaway env file in a fresh temp directory and returns the + * full path to the file. + */ + protected function writeFile(string $contents, string $filename = '.env'): string + { + return $this->writeInto($this->makeDir(), $filename, $contents); + } + + /** + * Writes a throwaway env file and returns the directory that contains it + * (for exercising directory-path loading). + */ + protected function writeDir(string $contents, string $filename = '.env'): string + { + $dir = $this->makeDir(); + $this->writeInto($dir, $filename, $contents); + + return $dir; + } + + /** + * Writes a file inside an existing directory and returns its full path. + */ + protected function writeInto(string $dir, string $filename, string $contents): string + { + $path = $dir . '/' . $filename; + file_put_contents($path, $contents); + + return $path; + } + + /** + * Creates an empty temp directory and returns its path. + */ + protected function makeDir(): string + { + $dir = sys_get_temp_dir() . '/dotenv-test-' . uniqid('', true); + mkdir($dir, 0777, true); + $this->tempDirs[] = $dir; + + return $dir; + } + + private function deleteTree(string $dir): void + { + if (!is_dir($dir)) { + return; + } + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $dir . '/' . $entry; + is_dir($path) ? $this->deleteTree($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/tests/EnvHelperTest.php b/tests/EnvHelperTest.php new file mode 100644 index 0000000..8fd22cf --- /dev/null +++ b/tests/EnvHelperTest.php @@ -0,0 +1,34 @@ +writeFile("HELPER_KEY=helper_value\n"); + DotENV::create($file); + + self::assertSame('helper_value', env('HELPER_KEY')); + } + + public function testHelperReturnsDefaultForMissingKey(): void + { + self::assertSame('default', env('MISSING_HELPER_KEY', 'default')); + } + + public function testHelperSharesStateWithFacade(): void + { + $file = $this->writeFile("SHARED=42\n"); + DotENV::create($file); + + self::assertSame(42, env('SHARED')); + self::assertSame(42, DotENV::get('SHARED')); + } +} diff --git a/tests/ExceptionTest.php b/tests/ExceptionTest.php new file mode 100644 index 0000000..9c6689f --- /dev/null +++ b/tests/ExceptionTest.php @@ -0,0 +1,34 @@ +getParentClass(); + + self::assertNotFalse($parent); + self::assertSame(InvalidArgumentException::class, $parent->getName()); + } + + public function testIsCatchableAsInvalidArgumentException(): void + { + $caught = null; + try { + throw new DotENVException('boom'); + } catch (InvalidArgumentException $e) { + $caught = $e; + } + + self::assertInstanceOf(DotENVException::class, $caught); + self::assertSame('boom', $caught->getMessage()); + } +} diff --git a/tests/FacadeTest.php b/tests/FacadeTest.php new file mode 100644 index 0000000..4f01568 --- /dev/null +++ b/tests/FacadeTest.php @@ -0,0 +1,65 @@ +writeFile("FACADE_KEY=facade_value\n"); + DotENV::create($file); + + self::assertSame('facade_value', DotENV::get('FACADE_KEY')); + self::assertSame('facade_value', DotENV::env('FACADE_KEY')); + } + + public function testGetReturnsDefaultThroughFacade(): void + { + self::assertSame('def', DotENV::get('MISSING_FACADE_KEY', 'def')); + } + + public function testInstanceReturnsSharedRepository(): void + { + $first = DotENV::instance(); + $second = DotENV::instance(); + + self::assertInstanceOf(Repository::class, $first); + self::assertSame($first, $second); + } + + public function testResetDropsTheSharedInstance(): void + { + $before = DotENV::instance(); + DotENV::reset(); + $after = DotENV::instance(); + + self::assertNotSame($before, $after); + } + + public function testFlushRemovesValuesThatWereLoaded(): void + { + $file = $this->writeFile("FLUSH_ME=value\n"); + DotENV::create($file); + self::assertSame('value', DotENV::get('FLUSH_ME')); + + DotENV::flush(); + + self::assertArrayNotHasKey('FLUSH_ME', $_ENV); + self::assertArrayNotHasKey('FLUSH_ME', $_SERVER); + self::assertFalse(getenv('FLUSH_ME')); + } + + public function testInstanceCallForwardingThroughMagicCall(): void + { + $file = $this->writeFile("MAGIC_CALL=ok\n"); + $facade = new DotENV(); + $facade->create($file); + + self::assertSame('ok', $facade->get('MAGIC_CALL')); + } +} diff --git a/tests/InterpolationTest.php b/tests/InterpolationTest.php new file mode 100644 index 0000000..65aca0a --- /dev/null +++ b/tests/InterpolationTest.php @@ -0,0 +1,71 @@ +load("SITE_URL=http://lvh.me\nPAGE_URL=\${SITE_URL}/page\n"); + + self::assertSame('http://lvh.me/page', $repo->get('PAGE_URL')); + } + + public function testMultipleVariablesOnOneLine(): void + { + // Regression: the greedy `\${(.+)}` pattern matched across both + // references and resolved to an empty string. + $repo = $this->load("HOST=localhost\nPORT=8080\nADDR=\${HOST}:\${PORT}\n"); + + self::assertSame('localhost:8080', $repo->get('ADDR')); + } + + public function testNestedInterpolation(): void + { + $repo = $this->load("A=root\nB=\${A}/b\nC=\${B}/c\n"); + + self::assertSame('root/b/c', $repo->get('C')); + } + + public function testMissingVariableResolvesToEmptyString(): void + { + $repo = $this->load("VALUE=\${DOES_NOT_EXIST}suffix\n"); + + self::assertSame('suffix', $repo->get('VALUE')); + } + + public function testSelfReferenceDoesNotRecurseForever(): void + { + // Regression: `A=${A}` used to recurse until the stack overflowed. + $repo = $this->load("A=\${A}\n"); + + self::assertSame('', $repo->get('A')); + } + + public function testMutualReferenceDoesNotRecurseForever(): void + { + $repo = $this->load("B=\${C}\nC=\${B}\n"); + + self::assertSame('', $repo->get('B')); + self::assertSame('', $repo->get('C')); + } + + public function testInterpolationStopsAtClosingBrace(): void + { + $repo = $this->load("NAME=world\nGREETING=hello-\${NAME}-!\n"); + + self::assertSame('hello-world-!', $repo->get('GREETING')); + } + + private function load(string $contents): Repository + { + $repo = new Repository(); + $repo->create($this->writeFile($contents)); + + return $repo; + } +} diff --git a/tests/LibBackwardsCompatibilityTest.php b/tests/LibBackwardsCompatibilityTest.php new file mode 100644 index 0000000..e81cf2a --- /dev/null +++ b/tests/LibBackwardsCompatibilityTest.php @@ -0,0 +1,36 @@ +writeFile("LEGACY=works\n"); + + $lib = new Lib(); + $lib->create($file); + + self::assertSame('works', $lib->get('LEGACY')); + } +} diff --git a/tests/ParsingTest.php b/tests/ParsingTest.php new file mode 100644 index 0000000..056f8bc --- /dev/null +++ b/tests/ParsingTest.php @@ -0,0 +1,131 @@ +load(<<get('REAL')); + self::assertNull($repo->get('# hash comment')); + } + + public function testIgnoresBlankLines(): void + { + $repo = $this->load("A=1\n\n\nB=2\n"); + + self::assertSame(1, $repo->get('A')); + self::assertSame(2, $repo->get('B')); + } + + public function testTrimsWhitespaceAroundKeyAndValue(): void + { + $repo = $this->load(" SPACED_KEY = spaced value \n"); + + self::assertSame('spaced value', $repo->get('SPACED_KEY')); + } + + public function testStripsDoubleQuotes(): void + { + $repo = $this->load('QUOTED="hello world"' . "\n"); + + self::assertSame('hello world', $repo->get('QUOTED')); + } + + public function testStripsSingleQuotes(): void + { + $repo = $this->load("QUOTED='hello world'\n"); + + self::assertSame('hello world', $repo->get('QUOTED')); + } + + public function testStripsQuotesEvenWithSpacesAroundEquals(): void + { + // Regression: the quote check used to run against the untrimmed + // value, so `KEY = "v"` kept its quotes. + $repo = $this->load('SITE = "http://lvh.me"' . "\n"); + + self::assertSame('http://lvh.me', $repo->get('SITE')); + } + + public function testKeepsHashInsideQuotes(): void + { + $repo = $this->load('COLOR="#ffffff"' . "\n"); + + self::assertSame('#ffffff', $repo->get('COLOR')); + } + + public function testPreservesLeadingHashInUnquotedValue(): void + { + // Regression: `#ffffff` used to be swallowed entirely as a comment. + $repo = $this->load("COLOR=#ffffff\n"); + + self::assertSame('#ffffff', $repo->get('COLOR')); + } + + public function testStripsInlineCommentAfterWhitespace(): void + { + $repo = $this->load("URL=http://lvh.me # inline comment\n"); + + self::assertSame('http://lvh.me', $repo->get('URL')); + } + + public function testStripsInlineCommentAfterQuotedValue(): void + { + $repo = $this->load('TOKEN="abc" # secret' . "\n"); + + self::assertSame('abc', $repo->get('TOKEN')); + } + + public function testDoesNotTreatHashWithoutLeadingSpaceAsComment(): void + { + $repo = $this->load("FRAGMENT=path#section\n"); + + self::assertSame('path#section', $repo->get('FRAGMENT')); + } + + public function testIgnoresLinesWithoutEqualsSign(): void + { + // Regression: a non-comment line with no '=' raised PHP warnings + // and defined a junk key. + $repo = $this->load("VALID=1\nLINE_WITHOUT_EQUALS\nOTHER=2\n"); + + self::assertSame(1, $repo->get('VALID')); + self::assertSame(2, $repo->get('OTHER')); + self::assertNull($repo->get('LINE_WITHOUT_EQUALS')); + } + + public function testStripsExportPrefix(): void + { + $repo = $this->load("export EXPORTED=shell\n"); + + self::assertSame('shell', $repo->get('EXPORTED')); + self::assertNull($repo->get('export EXPORTED')); + } + + public function testKeyWithEqualsInValue(): void + { + $repo = $this->load("DSN=mysql:host=localhost;dbname=app\n"); + + self::assertSame('mysql:host=localhost;dbname=app', $repo->get('DSN')); + } + + private function load(string $contents): Repository + { + $repo = new Repository(); + $repo->create($this->writeFile($contents)); + + return $repo; + } +} diff --git a/tests/PhpEnvFileTest.php b/tests/PhpEnvFileTest.php new file mode 100644 index 0000000..aab7f8a --- /dev/null +++ b/tests/PhpEnvFileTest.php @@ -0,0 +1,77 @@ +loadPhp(<<<'PHP' + 'string value', + 'BOOL' => true, + 'INT' => 13, + 'NULL' => null, + ]; + PHP); + + self::assertSame('string value', $repo->get('STR')); + self::assertTrue($repo->get('BOOL')); + self::assertSame(13, $repo->get('INT')); + self::assertNull($repo->get('NULL')); + } + + public function testInterpolatesStringValuesFromPhpFile(): void + { + $repo = $this->loadPhp(<<<'PHP' + 'http://lvh.me', + 'PAGE_URL' => '${SITE_URL}/page', + ]; + PHP); + + self::assertSame('http://lvh.me/page', $repo->get('PAGE_URL')); + } + + public function testNonArrayReturnThrows(): void + { + $file = $this->writeFile('expectException(DotENVException::class); + $this->expectExceptionMessage('must return an associative array'); + + (new Repository())->create($file); + } + + public function testNonArrayReturnIsSilentWhenDebugDisabled(): void + { + $file = $this->writeFile('create($file, false); + + self::assertNull((new Repository())->get('ANYTHING')); + } + + public function testLoadsPhpFileFromDirectory(): void + { + $dir = $this->writeDir(" 'yes'];", '.env.php'); + $repo = new Repository(); + $repo->create($dir); + + self::assertSame('yes', $repo->get('FROM_DIR')); + } + + private function loadPhp(string $contents): Repository + { + $repo = new Repository(); + $repo->create($this->writeFile($contents, '.env.php')); + + return $repo; + } +} diff --git a/tests/PriorityTest.php b/tests/PriorityTest.php new file mode 100644 index 0000000..4a275fd --- /dev/null +++ b/tests/PriorityTest.php @@ -0,0 +1,66 @@ +get('PRIORITY')); + } + + public function testServerTakesPriorityOverGetenv(): void + { + unset($_ENV['PRIORITY_SG']); + $_SERVER['PRIORITY_SG'] = 'from_server'; + $this->putenvValue('PRIORITY_SG', 'from_getenv'); + + self::assertSame('from_server', (new Repository())->get('PRIORITY_SG')); + } + + public function testFallsBackToGetenv(): void + { + unset($_ENV['ONLY_GETENV'], $_SERVER['ONLY_GETENV']); + $this->putenvValue('ONLY_GETENV', 'here'); + + self::assertSame('here', (new Repository())->get('ONLY_GETENV')); + } + + public function testReturnsDefaultWhenMissing(): void + { + self::assertSame('fallback', (new Repository())->get('TOTALLY_UNSET_KEY', 'fallback')); + } + + public function testDefaultIsNullByDefault(): void + { + self::assertNull((new Repository())->get('TOTALLY_UNSET_KEY')); + } + + public function testCreateDoesNotOverwriteExistingEnvValue(): void + { + $_ENV['EXISTING_KEY'] = 'original'; + $file = $this->writeFile("EXISTING_KEY=replacement\n"); + + (new Repository())->create($file); + + self::assertSame('original', $_ENV['EXISTING_KEY']); + self::assertSame('original', (new Repository())->get('EXISTING_KEY')); + } + + public function testCreatePopulatesAllThreeStores(): void + { + $file = $this->writeFile("WRITTEN=value\n"); + (new Repository())->create($file); + + self::assertSame('value', $_ENV['WRITTEN']); + self::assertSame('value', $_SERVER['WRITTEN']); + self::assertSame('value', getenv('WRITTEN')); + } +} diff --git a/tests/ValueTypeTest.php b/tests/ValueTypeTest.php new file mode 100644 index 0000000..90613cd --- /dev/null +++ b/tests/ValueTypeTest.php @@ -0,0 +1,96 @@ +load("VALUE={$literal}"); + + self::assertSame($expected, $repo->get('VALUE')); + } + + /** + * @return array + */ + public static function keywordProvider(): array + { + return [ + 'true' => ['true', true], + 'false' => ['false', false], + 'null' => ['null', null], + 'empty' => ['empty', ''], + 'TRUE upper' => ['TRUE', true], + 'False mixed' => ['False', false], + ]; + } + + public function testEmptyAssignmentBecomesEmptyString(): void + { + $repo = $this->load('BLANK='); + + self::assertSame('', $repo->get('BLANK')); + } + + public function testIntegerCoercion(): void + { + $repo = $this->load('PORT=8080'); + + self::assertSame(8080, $repo->get('PORT')); + } + + public function testNegativeIntegerCoercion(): void + { + $repo = $this->load('OFFSET=-42'); + + self::assertSame(-42, $repo->get('OFFSET')); + } + + public function testFloatCoercion(): void + { + $repo = $this->load('PI=3.14'); + + self::assertSame(3.14, $repo->get('PI')); + } + + /** + * @dataProvider preservedStringProvider + */ + public function testNonRoundTrippingNumbersStayStrings(string $literal): void + { + // Regression: aggressive intval()/floatval() used to mangle these. + $repo = $this->load("VALUE={$literal}"); + + self::assertSame($literal, $repo->get('VALUE')); + } + + /** + * @return array + */ + public static function preservedStringProvider(): array + { + return [ + 'leading zero' => ['007'], + 'leading plus' => ['+905551112233'], + 'beyond int max' => ['99999999999999999999'], + 'scientific' => ['1e3'], + 'phone with dashes' => ['555-12-34'], + ]; + } + + private function load(string $contents): Repository + { + $repo = new Repository(); + $repo->create($this->writeFile($contents)); + + return $repo; + } +} From fca6aa0d3d0932c7c5f380651152d4c10256e7ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Mon, 8 Jun 2026 17:25:50 +0300 Subject: [PATCH 2/2] Resolve circular references and consult `getenv()` in immutability check --- CHANGELOG.md | 11 ++++-- docs/env-file-format.md | 4 ++ docs/variable-interpolation.md | 23 ++++++++++-- src/Repository.php | 68 +++++++++++++++++++++++++++------- tests/DotEnvTestCase.php | 4 +- tests/InterpolationTest.php | 53 ++++++++++++++++++++++++++ tests/ParsingTest.php | 9 +++++ tests/PhpEnvFileTest.php | 12 ++++++ tests/PriorityTest.php | 15 ++++++++ 9 files changed, 178 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 073c136..2006fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,9 @@ documentation. The public API (`DotENV::create`, `get`, `env`, and the global "undefined array key" / "passing null" warnings and stored a junk key. Such lines are now ignored. - **Circular `${VAR}` references** (`A=${A}`, or `B=${C}` / `C=${B}`) recursed - until the stack overflowed. They now resolve to an empty string. + until the stack overflowed. They now resolve to an empty string while keeping + any literal text around the reference (`D=${D}-tail` → `-tail`), and the + result no longer depends on which name is read first. ### Changed @@ -42,8 +44,11 @@ documentation. The public API (`DotENV::create`, `get`, `env`, and the global when the conversion round-trips exactly. `007`, `+905551112233`, values beyond `PHP_INT_MAX`, and `1e3` now stay strings instead of being silently mangled. `13` and `3.14` still coerce as before. -- **Immutability check** now uses `array_key_exists()` instead of `isset()`, - so a pre-existing name whose value is `null` is still respected. +- **Immutability check** now uses `array_key_exists()` instead of `isset()` + (so a pre-existing name whose value is `null` is still respected) and also + consults `getenv()`. Previously a real environment variable visible only via + `getenv()` (when `variables_order` excludes `E`) could be silently + overwritten by a `.env` file. - Renamed the internal worker class `Lib` to **`Repository`**. `Lib` remains available as a deprecated alias. - Documentation, comments and PHPDoc are now in English and match the actual diff --git a/docs/env-file-format.md b/docs/env-file-format.md index 5542d4b..09a7312 100644 --- a/docs/env-file-format.md +++ b/docs/env-file-format.md @@ -85,6 +85,10 @@ An inline comment is still recognised after the closing quote: TOKEN = "abc" # secret # -> abc ``` +Escape sequences are **not** processed inside quotes — `\n`, `\t` and `\"` +are kept literally. If you need those characters, set them another way (for +example through a [`.env.php`](php-env-file.md) file). + > **Note:** quoting changes how the *line* is parsed, not how the *value* is > resolved later. `${VAR}` interpolation and type coercion happen when you call > `get()`, regardless of whether the value was quoted in the file. See diff --git a/docs/variable-interpolation.md b/docs/variable-interpolation.md index cb3560b..a73c3f5 100644 --- a/docs/variable-interpolation.md +++ b/docs/variable-interpolation.md @@ -47,8 +47,22 @@ DotENV::get('C'); // "root/b/c" (`$_ENV` → `$_SERVER` → `getenv()`), so a reference can point at a real environment variable, not just another line in the same file. +## How the referenced value is inserted + +A reference is replaced with the **string cast** of the referenced value, +then the whole surrounding value is coerced as usual. So referencing a +[coerced type](value-types.md) behaves like PHP's `(string)` cast: + +| `NAME` value | `${NAME}` inserts | `WRAP=${NAME}` becomes | +| ------------ | ----------------- | ---------------------- | +| `"text"` | `text` | `"text"` | +| `13` | `13` | `13` (int) | +| `true` | `1` | `1` (int) | +| `false` | *(empty)* | `""` | +| `null` | *(empty)* | `""` | + A non-scalar referenced value (an array or object loaded from a `.env.php` -file) resolves to an empty string inside an interpolation. +file) inserts an empty string. ## Missing references @@ -64,17 +78,20 @@ DotENV::get('VALUE'); // "suffix" ## Circular references -A self-reference or a cycle resolves to an empty string instead of recursing -forever: +A reference back to a name that is still being resolved (a self-reference or +a cycle) is replaced with an empty string instead of recursing forever. Any +literal text around the reference is kept: ```dotenv A = ${A} B = ${C} C = ${B} +D = ${D}-tail ``` ```php DotENV::get('A'); // "" DotENV::get('B'); // "" DotENV::get('C'); // "" +DotENV::get('D'); // "-tail" (the cyclic ${D} is dropped, "-tail" remains) ``` diff --git a/src/Repository.php b/src/Repository.php index 25802c1..081a37d 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -124,6 +124,11 @@ public function create(string $path, bool $debug = true): void * are coerced (`"true"`/`"false"`/`"null"`/`"empty"`, integers and * floats) and any `${VAR}` references are interpolated. * + * The resolved value is cached on first read, so later external changes + * to `$_ENV` / `$_SERVER` / `getenv()` for the same name are not picked + * up until {@see flush()} clears the cache. A name that is undefined is + * not cached, so it can still be defined and read later. + * * @param string $name The environment variable name. * @param mixed $default Returned when the variable is not defined. * @return mixed @@ -133,21 +138,47 @@ public function get(string $name, mixed $default = null): mixed if (\array_key_exists($name, $this->cache)) { return $this->cache[$name]; } + if (\in_array($name, $this->resolving, true)) { + // Re-entered while this same name is already being resolved: a + // circular `${VAR}` reference. Break the cycle with an empty + // string and do not cache it (the in-progress outer frame owns + // the cache entry). + return ''; + } if (\array_key_exists($name, $_ENV)) { - return $this->cache[$name] = $this->convert($_ENV[$name]); + return $this->cache[$name] = $this->resolve($name, $_ENV[$name]); } if (\array_key_exists($name, $_SERVER)) { - return $this->cache[$name] = $this->convert($_SERVER[$name]); + return $this->cache[$name] = $this->resolve($name, $_SERVER[$name]); } $env = getenv($name); if ($env !== false) { - return $this->cache[$name] = $this->convert($env); + return $this->cache[$name] = $this->resolve($name, $env); } return $default; } + /** + * Coerces and interpolates a raw value while marking `$name` as being + * resolved, so a `${VAR}` reference back to it short-circuits in + * {@see get()} instead of recursing. + * + * @param string $name + * @param mixed $raw + * @return mixed + */ + private function resolve(string $name, mixed $raw): mixed + { + $this->resolving[] = $name; + try { + return $this->convert($raw); + } finally { + array_pop($this->resolving); + } + } + /** * Alias of {@see get()}. * @@ -373,7 +404,14 @@ private function normaliseValue(string $value): string /** * Writes parsed values into the environment without overwriting any name - * that already exists in `$_ENV` or `$_SERVER`. + * that is already defined. + * + * A name is considered already defined if it is present in `$_ENV`, + * `$_SERVER`, or `getenv()` — the same three sources {@see get()} reads + * from. Checking `getenv()` too matters because a real environment + * variable can be visible there while absent from the superglobals (when + * `variables_order` excludes `E`); without it, a `.env` file would + * silently clobber a genuine environment variable. * * @param array $values * @return void @@ -381,7 +419,9 @@ private function normaliseValue(string $value): string private function store(array $values): void { foreach ($values as $key => $value) { - if (\array_key_exists($key, $_ENV) || \array_key_exists($key, $_SERVER)) { + if (\array_key_exists($key, $_ENV) + || \array_key_exists($key, $_SERVER) + || getenv($key) !== false) { continue; } @@ -447,8 +487,13 @@ private function toScalar(string $data): int|float|string } /** - * Replaces every `${VAR}` reference with the value of `VAR`, guarding - * against circular references (which resolve to an empty string). + * Replaces every `${VAR}` reference with the value of `VAR`. + * + * Cycle handling lives in {@see get()} / {@see resolve()}: a reference + * back to a name that is already being resolved short-circuits to an + * empty string. A reference to an undefined or non-scalar value also + * resolves to an empty string; a scalar is inserted via PHP's string + * cast (so `true` becomes `"1"`, `false`/`null` become `""`). * * @param string $data * @return string @@ -457,16 +502,11 @@ private function interpolate(string $data): string { $result = preg_replace_callback('/\${([^}]+)}/', function (array $match): string { $name = trim($match[1], " \t\n\r\0\x0B\"'"); - if ($name === '' || \in_array($name, $this->resolving, true)) { + if ($name === '') { return ''; } - $this->resolving[] = $name; - try { - $value = $this->get($name); - } finally { - array_pop($this->resolving); - } + $value = $this->get($name); return \is_scalar($value) ? (string) $value : ''; }, $data); diff --git a/tests/DotEnvTestCase.php b/tests/DotEnvTestCase.php index 213093e..ba67bde 100644 --- a/tests/DotEnvTestCase.php +++ b/tests/DotEnvTestCase.php @@ -124,7 +124,9 @@ protected function writeInto(string $dir, string $filename, string $contents): s protected function makeDir(): string { $dir = sys_get_temp_dir() . '/dotenv-test-' . uniqid('', true); - mkdir($dir, 0777, true); + if (!mkdir($dir, 0777, true) && !is_dir($dir)) { + self::fail(\sprintf('Could not create temp directory "%s".', $dir)); + } $this->tempDirs[] = $dir; return $dir; diff --git a/tests/InterpolationTest.php b/tests/InterpolationTest.php index 65aca0a..cb52bbd 100644 --- a/tests/InterpolationTest.php +++ b/tests/InterpolationTest.php @@ -61,6 +61,59 @@ public function testInterpolationStopsAtClosingBrace(): void self::assertSame('hello-world-!', $repo->get('GREETING')); } + public function testSelfReferenceWithSurroundingTextResolvesOnce(): void + { + // Regression: the cyclic value used to be double-counted ("-tail-tail") + // because the recursive lookup re-resolved the whole value and cached + // the partial result. + $repo = $this->load("A=\${A}-tail\nB=pre-\${B}-post\n"); + + self::assertSame('-tail', $repo->get('A')); + self::assertSame('pre--post', $repo->get('B')); + } + + public function testRepeatedReadOfCyclicValueIsStable(): void + { + $repo = $this->load("A=\${A}-tail\n"); + + self::assertSame($repo->get('A'), $repo->get('A')); + } + + public function testWhitespaceInsideBracesIsIgnored(): void + { + $repo = $this->load("NAME=world\nGREETING=hello \${ NAME }\n"); + + self::assertSame('hello world', $repo->get('GREETING')); + } + + public function testEmptyBracesAreLeftLiteral(): void + { + // `${}` has nothing between the braces, so the reference pattern does + // not match it and it is kept verbatim. + $repo = $this->load('VALUE=a${}b' . "\n"); + + self::assertSame('a${}b', $repo->get('VALUE')); + } + + public function testWhitespaceOnlyBracesResolveToEmpty(): void + { + // `${ }` matches the pattern but the name trims to empty. + $repo = $this->load('VALUE=a${ }b' . "\n"); + + self::assertSame('ab', $repo->get('VALUE')); + } + + public function testScalarReferencesUsePhpStringCast(): void + { + // Documented behaviour: a reference is inserted via PHP's string cast, + // then the whole value is re-coerced. So a referenced `true` becomes + // "1" (and is then coerced to int 1); a referenced `false` becomes "". + $repo = $this->load("FLAG=true\nWRAP=\${FLAG}\nOFF=false\nOFF_WRAP=x\${OFF}y\n"); + + self::assertSame(1, $repo->get('WRAP')); + self::assertSame('xy', $repo->get('OFF_WRAP')); + } + private function load(string $contents): Repository { $repo = new Repository(); diff --git a/tests/ParsingTest.php b/tests/ParsingTest.php index 056f8bc..cec5fbc 100644 --- a/tests/ParsingTest.php +++ b/tests/ParsingTest.php @@ -114,6 +114,15 @@ public function testStripsExportPrefix(): void self::assertNull($repo->get('export EXPORTED')); } + public function testExportPrefixRequiresAPlainSpace(): void + { + // The `export ` strip is matched literally, so a tab after `export` + // is not treated as the shell prefix. + $repo = $this->load("export\tTABKEY=value\n"); + + self::assertNull($repo->get('TABKEY')); + } + public function testKeyWithEqualsInValue(): void { $repo = $this->load("DSN=mysql:host=localhost;dbname=app\n"); diff --git a/tests/PhpEnvFileTest.php b/tests/PhpEnvFileTest.php index aab7f8a..dba0d19 100644 --- a/tests/PhpEnvFileTest.php +++ b/tests/PhpEnvFileTest.php @@ -40,6 +40,18 @@ public function testInterpolatesStringValuesFromPhpFile(): void self::assertSame('http://lvh.me/page', $repo->get('PAGE_URL')); } + public function testNonStringValuesAreNotPushedToGetenv(): void + { + // putenv() only accepts strings, so non-string .env.php values live in + // $_ENV / $_SERVER (and get()), but not in getenv(). This locks that + // documented limitation in place. + $repo = $this->loadPhp(" 8080];"); + + self::assertSame(8080, $repo->get('PORT_INT')); + self::assertFalse(getenv('PORT_INT')); + self::assertSame(8080, $_ENV['PORT_INT']); + } + public function testNonArrayReturnThrows(): void { $file = $this->writeFile('get('EXISTING_KEY')); } + public function testCreateDoesNotOverwriteGetenvOnlyValue(): void + { + // A real environment variable can be visible via getenv() while + // absent from $_ENV/$_SERVER (variables_order without "E"). It must + // still win over a .env file value. + unset($_ENV['GETENV_ONLY'], $_SERVER['GETENV_ONLY']); + $this->putenvValue('GETENV_ONLY', 'real-from-environment'); + + $file = $this->writeFile("GETENV_ONLY=overwritten-by-file\n"); + (new Repository())->create($file); + + self::assertSame('real-from-environment', getenv('GETENV_ONLY')); + self::assertSame('real-from-environment', (new Repository())->get('GETENV_ONLY')); + } + public function testCreatePopulatesAllThreeStores(): void { $file = $this->writeFile("WRITTEN=value\n");