Repository: thephpleague/flysystem Branch: 3.x Commit: 254b1595b16b Files: 293 Total size: 720.5 KB Directory structure: gitextract_99z1k6qe/ ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── Bug.md │ │ ├── Feature_Request.md │ │ └── Question.md │ ├── release.yml │ ├── stale.yml │ └── workflows/ │ ├── publish-subsplits.yml │ ├── quality-assurance.yml │ └── set-subsplit-default-branch.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── INFO.md ├── LICENSE ├── bin/ │ ├── .gitignore │ ├── check-versions.php │ ├── close-subsplit-prs.yml │ ├── set-flysystem-version.php │ ├── tools.php │ └── update-subsplit-closers.php ├── composer.json ├── config.subsplit-publish.json ├── docker-compose.yml ├── mocked-functions.php ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.php ├── phpunit.xml.dist ├── readme.md ├── src/ │ ├── AdapterTestUtilities/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── ExceptionThrowingFilesystemAdapter.php │ │ ├── FilesystemAdapterTestCase.php │ │ ├── README.md │ │ ├── RetryOnTestException.php │ │ ├── ToxiproxyManagement.php │ │ ├── composer.json │ │ ├── test-functions.php │ │ └── test_files/ │ │ └── unknown-mime-type.md5 │ ├── AsyncAwsS3/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── AsyncAwsS3Adapter.php │ │ ├── AsyncAwsS3AdapterTest.php │ │ ├── LICENSE │ │ ├── PortableVisibilityConverter.php │ │ ├── README.md │ │ ├── S3ClientStub.php │ │ ├── VisibilityConverter.php │ │ └── composer.json │ ├── AwsS3V3/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── AwsS3V3Adapter.php │ │ ├── AwsS3V3AdapterTest.php │ │ ├── LICENSE │ │ ├── PortableVisibilityConverter.php │ │ ├── README.md │ │ ├── S3ClientStub.php │ │ ├── VisibilityConverter.php │ │ └── composer.json │ ├── AzureBlobStorage/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── AzureBlobStorageAdapter.php │ │ ├── AzureBlobStorageAdapterTest.php │ │ ├── LICENSE │ │ ├── README.md │ │ └── composer.json │ ├── CalculateChecksumFromStream.php │ ├── ChecksumAlgoIsNotSupported.php │ ├── ChecksumProvider.php │ ├── Config.php │ ├── ConfigTest.php │ ├── CorruptedPathDetected.php │ ├── DecoratedAdapter.php │ ├── DirectoryAttributes.php │ ├── DirectoryAttributesTest.php │ ├── DirectoryListing.php │ ├── DirectoryListingTest.php │ ├── ExceptionInformationTest.php │ ├── FileAttributes.php │ ├── FileAttributesTest.php │ ├── Filesystem.php │ ├── FilesystemAdapter.php │ ├── FilesystemException.php │ ├── FilesystemOperationFailed.php │ ├── FilesystemOperator.php │ ├── FilesystemReader.php │ ├── FilesystemTest.php │ ├── FilesystemWriter.php │ ├── Ftp/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── ConnectionProvider.php │ │ ├── ConnectivityChecker.php │ │ ├── ConnectivityCheckerThatCanFail.php │ │ ├── FtpAdapter.php │ │ ├── FtpAdapterTest.php │ │ ├── FtpAdapterTestCase.php │ │ ├── FtpConnectionException.php │ │ ├── FtpConnectionOptions.php │ │ ├── FtpConnectionProvider.php │ │ ├── FtpConnectionProviderTest.php │ │ ├── FtpdAdapterTest.php │ │ ├── InvalidListResponseReceived.php │ │ ├── LICENSE │ │ ├── NoopCommandConnectivityChecker.php │ │ ├── NoopCommandConnectivityCheckerTest.php │ │ ├── README.md │ │ ├── RawListFtpConnectivityChecker.php │ │ ├── RawListFtpConnectivityCheckerTest.php │ │ ├── StubConnectionProvider.php │ │ ├── UnableToAuthenticate.php │ │ ├── UnableToConnectToFtpHost.php │ │ ├── UnableToEnableUtf8Mode.php │ │ ├── UnableToMakeConnectionPassive.php │ │ ├── UnableToResolveConnectionRoot.php │ │ ├── UnableToSetFtpOption.php │ │ └── composer.json │ ├── GoogleCloudStorage/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── GoogleCloudStorageAdapter.php │ │ ├── GoogleCloudStorageAdapterTest.php │ │ ├── GoogleCloudStorageAdapterWithoutAclTest.php │ │ ├── LICENSE │ │ ├── PortableVisibilityHandler.php │ │ ├── README.md │ │ ├── StubRiggedBucket.php │ │ ├── StubStorageClient.php │ │ ├── UniformBucketLevelAccessVisibility.php │ │ ├── VisibilityHandler.php │ │ └── composer.json │ ├── GridFS/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── GridFSAdapter.php │ │ ├── GridFSAdapterTest.php │ │ ├── LICENSE │ │ ├── README.md │ │ └── composer.json │ ├── InMemory/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── InMemoryFile.php │ │ ├── InMemoryFilesystemAdapter.php │ │ ├── InMemoryFilesystemAdapterTest.php │ │ ├── LICENSE │ │ ├── README.md │ │ ├── StaticInMemoryAdapterRegistry.php │ │ ├── StaticInMemoryAdapterRegistryTest.php │ │ └── composer.json │ ├── InvalidStreamProvided.php │ ├── InvalidVisibilityProvided.php │ ├── Local/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── FallbackMimeTypeDetector.php │ │ ├── LICENSE │ │ ├── LocalFilesystemAdapter.php │ │ ├── LocalFilesystemAdapterTest.php │ │ ├── README.md │ │ └── composer.json │ ├── MountManager.php │ ├── MountManagerTest.php │ ├── PathNormalizer.php │ ├── PathPrefixer.php │ ├── PathPrefixerTest.php │ ├── PathPrefixing/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── LICENSE │ │ ├── PathPrefixedAdapter.php │ │ ├── PathPrefixedAdapterTest.php │ │ ├── README.md │ │ └── composer.json │ ├── PathTraversalDetected.php │ ├── PhpseclibV2/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── ConnectionProvider.php │ │ ├── ConnectivityChecker.php │ │ ├── FixatedConnectivityChecker.php │ │ ├── README.md │ │ ├── SftpAdapter.php │ │ ├── SftpAdapterTest.php │ │ ├── SftpConnectionProvider.php │ │ ├── SftpConnectionProviderTest.php │ │ ├── SftpStub.php │ │ ├── SimpleConnectivityChecker.php │ │ ├── StubSftpConnectionProvider.php │ │ ├── UnableToAuthenticate.php │ │ ├── UnableToConnectToSftpHost.php │ │ ├── UnableToEstablishAuthenticityOfHost.php │ │ ├── UnableToLoadPrivateKey.php │ │ └── composer.json │ ├── PhpseclibV3/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── ConnectionProvider.php │ │ ├── ConnectivityChecker.php │ │ ├── FixatedConnectivityChecker.php │ │ ├── LICENSE │ │ ├── README.md │ │ ├── SftpAdapter.php │ │ ├── SftpAdapterTest.php │ │ ├── SftpConnectionProvider.php │ │ ├── SftpConnectionProviderTest.php │ │ ├── SftpStub.php │ │ ├── SimpleConnectivityChecker.php │ │ ├── StubSftpConnectionProvider.php │ │ ├── UnableToAuthenticate.php │ │ ├── UnableToConnectToSftpHost.php │ │ ├── UnableToEstablishAuthenticityOfHost.php │ │ ├── UnableToLoadPrivateKey.php │ │ └── composer.json │ ├── PortableVisibilityGuard.php │ ├── ProxyArrayAccessToProperties.php │ ├── ReadOnly/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── ReadOnlyFilesystemAdapter.php │ │ ├── ReadOnlyFilesystemAdapterTest.php │ │ └── composer.json │ ├── ResolveIdenticalPathConflict.php │ ├── StorageAttributes.php │ ├── SymbolicLinkEncountered.php │ ├── UnableToCheckDirectoryExistence.php │ ├── UnableToCheckExistence.php │ ├── UnableToCheckFileExistence.php │ ├── UnableToCopyFile.php │ ├── UnableToCreateDirectory.php │ ├── UnableToDeleteDirectory.php │ ├── UnableToDeleteFile.php │ ├── UnableToGeneratePublicUrl.php │ ├── UnableToGenerateTemporaryUrl.php │ ├── UnableToListContents.php │ ├── UnableToMountFilesystem.php │ ├── UnableToMoveFile.php │ ├── UnableToProvideChecksum.php │ ├── UnableToReadFile.php │ ├── UnableToResolveFilesystemMount.php │ ├── UnableToRetrieveMetadata.php │ ├── UnableToSetVisibility.php │ ├── UnableToWriteFile.php │ ├── UnixVisibility/ │ │ ├── PortableVisibilityConverter.php │ │ ├── PortableVisibilityConverterTest.php │ │ └── VisibilityConverter.php │ ├── UnreadableFileEncountered.php │ ├── UrlGeneration/ │ │ ├── ChainedPublicUrlGenerator.php │ │ ├── ChainedPublicUrlGeneratorTest.php │ │ ├── PrefixPublicUrlGenerator.php │ │ ├── PublicUrlGenerator.php │ │ ├── ShardedPrefixPublicUrlGenerator.php │ │ └── TemporaryUrlGenerator.php │ ├── Visibility.php │ ├── WebDAV/ │ │ ├── .gitattributes │ │ ├── .github/ │ │ │ └── workflows/ │ │ │ └── close-subsplit-prs.yaml │ │ ├── ByteMarkWebDAVServerTest.php │ │ ├── LICENSE │ │ ├── README.md │ │ ├── SabreServerTest.php │ │ ├── UrlPrefixingClientStub.php │ │ ├── WebDAVAdapter.php │ │ ├── WebDAVAdapterTestCase.php │ │ ├── composer.json │ │ └── resources/ │ │ ├── .gitignore │ │ └── server.php │ ├── WhitespacePathNormalizer.php │ ├── WhitespacePathNormalizerTest.php │ └── ZipArchive/ │ ├── .gitattributes │ ├── .github/ │ │ └── workflows/ │ │ └── close-subsplit-prs.yaml │ ├── FilesystemZipArchiveProvider.php │ ├── LICENSE │ ├── NoRootPrefixZipArchiveAdapterTest.php │ ├── PrefixedRootZipArchiveAdapterTest.php │ ├── README.md │ ├── StubZipArchive.php │ ├── StubZipArchiveProvider.php │ ├── UnableToCreateParentDirectory.php │ ├── UnableToOpenZipArchive.php │ ├── ZipArchiveAdapter.php │ ├── ZipArchiveAdapterTestCase.php │ ├── ZipArchiveException.php │ ├── ZipArchiveProvider.php │ └── composer.json └── test_files/ ├── .gitignore ├── sftp/ │ ├── id_rsa │ ├── id_rsa.pub │ ├── ssh_host_ed25519_key │ ├── ssh_host_ed25519_key.pub │ ├── ssh_host_rsa_key │ ├── ssh_host_rsa_key.pub │ ├── sshd_custom_configs.sh │ ├── unknown.key │ └── users.conf ├── toxiproxy/ │ └── toxiproxy.json ├── wait_for_ftp.php └── wait_for_sftp.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git ================================================ FILE: .editorconfig ================================================ [*] indent_style = space [*.yml] indent_size = 2 [composer.json] indent_size = 4 [*.php] end_of_line = lf insert_final_newline = true ================================================ FILE: .gitattributes ================================================ * text=auto /.docker export-ignore /.dockerignore export-ignore /docker-compose.yml export-ignore /config.subsplit-publish.json export-ignore /.editorconfig export-ignore /.php-cs-fixer.dist.php export-ignore /phpstan.neon export-ignore /phpstan-baseline.neon export-ignore /phpunit.php export-ignore /phpunit.xml.dist export-ignore /.travis.yml export-ignore /.scrutinizer.yml export-ignore /CHANGELOG.md export-ignore /CODE_OF_CONDUCT.md export-ignore /deprecations.md export-ignore /docker-composer.yml export-ignore /README.md export-ignore /CODE_OF_CONDUCT.md export-ignore /.github export-ignore /src/AsyncAwsS3 export-ignore /src/AwsS3V3 export-ignore /src/GoogleCloudStorage export-ignore /src/Ftp export-ignore /src/InMemory export-ignore /src/PhpseclibV2 export-ignore /src/PhpseclibV3 export-ignore /src/AdapterTestUtilities export-ignore /src/AzureBlobStorage export-ignore /src/ZipArchive export-ignore /src/WebDAV export-ignore /src/PathPrefixing export-ignore /src/Local export-ignore /src/ReadOnly export-ignore /src/GridFS export-ignore /.gitattributes export-ignore /.gitignore export-ignore /bin/ export-ignore /mocked-functions.php export-ignore /test_files/ export-ignore /**/*Test.php export-ignore /**/*Stub.php export-ignore /**/Stub*.php export-ignore ================================================ FILE: .github/ISSUE_TEMPLATE/Bug.md ================================================ --- name: 🐛 Bug about: Did you encounter a bug? --- ### Bug Report | Q | A | |-------------------|---------| | Flysystem Version | x.y.z | | Adapter Name | example | | Adapter version | x.y.z | #### Summary #### How to reproduce ================================================ FILE: .github/ISSUE_TEMPLATE/Feature_Request.md ================================================ --- name: 🎉 Feature Request about: Do you have a new feature in mind? --- ### Feature Request | Q | A | |-------------------|---------| | Flysystem Version | x.y.z | | Adapter Name | example | | Adapter version | x.y.z | #### Scenario / Use-case #### Summary ================================================ FILE: .github/ISSUE_TEMPLATE/Question.md ================================================ --- name: ❓ Question about: Are you unsure about something? --- ### Question | Q | A | |-------------------|---------| | Flysystem Version | x.y.z | | Adapter Name | example | | Adapter version | x.y.z | ================================================ FILE: .github/release.yml ================================================ changelog: categories: - title: Breaking Changes 🛠 labels: - Semver-Major - breaking-change - title: Exciting New Features 🎉 labels: - Semver-Minor - enhancement - title: Other Changes labels: - "*" ================================================ FILE: .github/stale.yml ================================================ # Number of days of inactivity before an issue becomes stale daysUntilStale: 60 # Number of days of inactivity before a stale issue is closed daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - keep open - 2.0 ideas # Label to use when marking an issue as stale staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false ================================================ FILE: .github/workflows/publish-subsplits.yml ================================================ --- name: Sub-Split Publishing on: push: branches: - 3.x create: tags: - '3.*' delete: tags: - '3.*' jobs: publish_subsplits: runs-on: ubuntu-latest name: Publish package sub-splits steps: - uses: actions/checkout@v4 with: fetch-depth: '0' persist-credentials: 'false' - uses: frankdejonge/use-github-token@1.1.0 with: authentication: 'frankdejonge:${{ secrets.PERSONAL_ACCESS_TOKEN }}' user_name: 'Frank de Jonge' user_email: 'info@frenky.net' - name: Cache splitsh-lite id: splitsh-cache uses: actions/cache@v4 with: path: './.splitsh' key: '${{ runner.os }}-splitsh' - uses: frankdejonge/use-subsplit-publish@1.1.0 with: source-branch: '3.x' config-path: './config.subsplit-publish.json' splitsh-path: './.splitsh/splitsh-lite' splitsh-version: 'v1.0.1' ================================================ FILE: .github/workflows/quality-assurance.yml ================================================ --- name: Quality Assurance concurrency: group: ${{ github.ref }} cancel-in-progress: true on: push: paths: - src/**/*.php - .github/workflows/quality-assurance.yml branches: - 3.x - 4.x pull_request: paths: - src/**/*.php - .github/workflows/quality-assurance.yml branches: - 3.x - 4.x schedule: - cron: "5 1 * * *" env: FLYSYSTEM_AWS_S3_KEY: '${{ secrets.FLYSYSTEM_AWS_S3_KEY }}' FLYSYSTEM_AWS_S3_SECRET: '${{ secrets.FLYSYSTEM_AWS_S3_SECRET }}' FLYSYSTEM_AWS_S3_BUCKET: '${{ secrets.FLYSYSTEM_AWS_S3_BUCKET }}' MONGODB_URI: 'mongodb://127.0.0.1:27017/' FLYSYSTEM_TEST_DANGEROUS_THINGS: "yes" FLYSYSTEM_TEST_SFTP: "yes" jobs: phpunit: name: PHPUnit tests on ${{ matrix.php }} ${{ matrix.composer-flags }} runs-on: ubuntu-latest continue-on-error: ${{ matrix.experimental }} strategy: fail-fast: false matrix: php: [ '8.2', '8.3', '8.4', '8.5' ] composer-flags: [ '' ] experimental: [false] phpstan: [true] phpunit-flags: [ '--coverage-text' ] # include: # - php: '8.2' # experimental: false # phpstan: false # phpunit-flags: '--no-coverage' # - php: '8.3' # experimental: false # phpstan: false # phpunit-flags: '--no-coverage' # - php: '8.4' # experimental: false # phpstan: false # phpunit-flags: '--no-coverage' steps: - uses: actions/checkout@v4 - run: docker compose -f docker-compose.yml up -d - name: Start an SSH Agent uses: frankdejonge/use-ssh-agent@1.1.0 - run: chmod 0400 ./test_files/sftp/id_* - id: ssh_agent run: ssh-add ./test_files/sftp/id_rsa - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} extensions: mongodb coverage: pcov tools: composer:v2 - run: composer update --no-progress ${{ matrix.composer-flags }} - run: php test_files/wait_for_sftp.php - run: php test_files/wait_for_ftp.php 2121 - run: php test_files/wait_for_ftp.php 2122 - run: COMPOSER_OPTS='${{ matrix.composer-flags }}' vendor/bin/phpunit ${{ matrix.phpunit-flags }} - run: vendor/bin/phpstan analyse if: ${{ matrix.phpstan }} - run: vendor/bin/php-cs-fixer fix --diff --dry-run continue-on-error: true if: ${{ matrix.php == '8.0' }} ================================================ FILE: .github/workflows/set-subsplit-default-branch.yml ================================================ --- name: Update default sub-split branch on: workflow_dispatch jobs: set-default-branch: name: Set default git branch runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: '0' persist-credentials: 'false' - uses: actions/github-script@v7 with: github-token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} script: | const fs = require('fs'); let splits = JSON.parse(fs.readFileSync('config.subsplit-publish.json'))['sub-splits']; for (let split of splits) { const { groups: { repo } } = /git@github\.com:thephpleague\/(?.*)\.git/.exec(split.target); console.log(`Found repo ${repo}`); await github.rest.repos.update({ owner: 'thephpleague', repo, default_branch: '3.x', }); } ================================================ FILE: .gitignore ================================================ /vendor/ /coverage/ /.phpunit.result.cache /phpunit.xml /composer.lock /index_*.php /.php-cs-fixer.php /.php-cs-fixer.cache /google-cloud-service-account.json .idea ================================================ FILE: .php-cs-fixer.dist.php ================================================ in([__DIR__ . '/']) ->exclude(__DIR__ . '/vendor'); return (new PhpCsFixer\Config()) ->setRules([ '@PSR2' => true, 'array_syntax' => ['syntax' => 'short'], 'binary_operator_spaces' => true, 'single_line_after_imports' => true, 'blank_line_before_statement' => ['statements' => ['return']], 'cast_spaces' => true, 'concat_space' => ['spacing' => 'one'], 'no_singleline_whitespace_before_semicolons' => true, 'not_operator_with_space' => true, 'no_unused_imports' => true, 'phpdoc_align' => false, 'phpdoc_indent' => true, 'phpdoc_no_access' => true, 'phpdoc_no_alias_tag' => true, 'phpdoc_no_package' => true, 'phpdoc_scalar' => true, 'phpdoc_separation' => true, 'phpdoc_summary' => true, 'phpdoc_to_comment' => true, 'phpdoc_trim' => true, 'single_blank_line_at_eof' => true, 'ternary_operator_spaces' => true, 'ordered_imports' => [ 'sort_algorithm' => 'alpha', 'imports_order' => ['const', 'class', 'function'], ], 'no_extra_blank_lines' => true, 'no_whitespace_in_blank_line' => true, 'nullable_type_declaration_for_default_null_value' => true, ]) ->setFinder($finder); ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## 3.32.0 - 2026-02-25 ### Changes - [AwsS3V3] Allow SSE-C options when fetching file metadata ## 3.31.0 - 2026-01-23 ### Changes - [AsyncAwsS3] Allow V3 ## 3.30.2 - 2025-11-10 ### Fixes - [Local] Clear last error for rename and copy operations (#1883) ## 3.30.1 - 2025-10-20 ### Fixes - Ensure listing directories called "0" do not produce unfiltered listings. ## 3.30.0 - 2025-06-25 ### Changes - [MongoDB] Allow v2 - [AsyncAWS] Encode/decode object identifiers - [GoogleCloudStorage] Add option to stream responses - [Local] Clear stat cache consistently - [SFTP] Add option to disconnect connections on destruction - [WebDAV] Deal with 405 case when trying to create a directory that already exists. ## 3.29.1 - 2024-10-08 ### Fixes - Normalise path for checksum call ## 3.29.0 - 2024-09-29 ### Changes - [FTP] Add error context from error messages - [SFTP] overwrite on move - [AWS S3] same path copy/move no-op - [Async S3] transform error to flysystem errors ## 3.28.0 - 2024-05-22 ### Added - MongoDB GridFS adapter (by @GromNaN) ### Fixed - PHP 8.3 directory listing issue for the FTP adapter (by @lanz1) ## 3.27.0 - 2024-04-07 ### Fixed - Corrected AWS SSE-C Options - Handle MetadataDirective gracefully. ## 3.26.0 - 2024-03-25 ### Fixed - Make SFTP connectivity pinging an opt-in feature. ### Added - Add `add_content_md5` option to AWS S3 (#1774) - Added AWS SSE-C options (#1773) ## 3.25.1 - 2024-03-16 ### Fixed - Cleanup connection instance after disconnecting SFTP connection. - Fix upcoming PHP 8.4 deprecations (#1772) ## 3.25.0 - 2024-03-09 ### Added - [MountManager] added ability to (dangerously) mount additional filesystems - [FTP] added `disconnect` method to proactively close connections - [SFTP V3] added `disconnect` method to proactively close connections ## 3.24.0 - 2024-02-04 ### Fixes - Updated method signatures to match upgraded dependency signatures for overrides (#1748, #1746) - Added missing path prefixing in FTP implementation (#1747) ### Changes - Updated string assertions to use PHP 8 functions (#1750, #1749)) ## 3.23.1 - 2024-01-26 ### Changes - Updated license year ## 3.23.0 - 2023-12-04 ### Fixed - Fixed upstream regression caused by resolving inconclusive mime-type. ### Added - Made inconclusive mime-type resolving configurable on the local adapter. ## 3.22.0 - 2023-12-03 ### Changes - Prevent double directory creation with lazy root creation for Local filesystem. ### Fixes - Resolve to "inconclusive" mimetype instead of causing a type error by @GuySartorelli - Corrected spelling of a configuration key for the Azure adapter by @shineability ### Additions - MountManager::extend allows for immutable dynamic extension of the mount manager. - Added a new abstract DecoratedAdapter for easier decoration of adapters by @jnoordsij ## 3.21.0 - 2023-11-18 ### Changes - Retain visibility on local copy for local FS, in line with other adapter (#1730) by @jnoordsij ## 3.20.0 - 2023-11-14 ### Changed - Normalise paths for public and temporary URLs (#1727) ## 3.19.0 - 2023-11-07 ### Added - Configuration option to specify if visibility should be retained during copy/move operations - InMemoryFilesystemAdapter now supports visibility changes on move and copy. - Default visibility options are ignored when moving/copying while respecting visibility retention settings. - Local filesystem implementation now allows setting visibility on move and copy. ## 3.18.0 - 2023-10-05 ### Added - Configuration option to specify how to handle same path copy/move operations (#1715) ## 3.17.0 - 2023-10-05 ### Added - [AsyncAWS] Added support for version 2.0 of async-aws/{s3,simple-s3} ## 3.16.0 - 2023-09-07 ### Added - [AsyncAws] Allow specifying `get_object_options` for temporary URL generation ### Fixed - [ZipArchive] override on move - [WebDAV] encode path for propfind actions - [PathPrefixing] [#1686](https://github.com/thephpleague/flysystem/issues/1686) ## 3.15.1 - 2023-05-04 ### Fixed - Remove duplicate class caused by package extractin and inclusion ## 3.15.0 - 2023-05-04 ### Added - Extracted the local adapter as a standalone package ### Changed - Removed readme's from shipped artefacts. ## 3.14.0 - 2023-04-11 ### Added - Made disabling stat cache configurable for SFTP adapters. ## 3.13.0 - 2023-04-11 ### Fixed - AsyncAwsS3 object deletion now chunks per 100 objects to prevent memory exhaustion - AsyncAwsS3 now disregards top-level directories from listings - LocalAdapter now deals with file deletions during directory listings gracefully. ### Added - DirectoryListing now supports correct phpstan for map and filter methods. - FTP/SFTP added config option to detect the mime-type using the path alone (prevents a read) - SFTP now supports PuTTY style private keys - ## 3.12.3 - 2023-02-18 ### Fixed - [Google Cloud Storage] Fixed ACL error for uniform bucker ACL copy operations. - - ## 3.12.2 - 2023-01-19 ### Fixed - [AWS S3] Corrected param order for doesObjectExistV2 call ## 3.12.1 - 2023-01-06 ### Fixed - [AWS S3] Use doesObjectExistV2 to prevent false positive respomnses. ## 3.12.0 - 2022-12-20 ### Added - [Core] Chained public URL generation strategy ### Fixed - [WebDAV] Handle cases where the content listing returns entries with URL prefixes. - [Local] Ensure correct implicit root creations happens on windows. - [ZipArchive] Fix incorrect zip contents listing for top-level directory. ## 3.11.0 - 2022-12-02 ### Added - [Google Cloud Storage] Added `UniformBucketLevelAccessVisibility` to allow buckets with uniform bucket-level access policies. ## 3.10.4 - 2022-11-26 ### Changed - [I became a dad, meet Tim](https://twitter.com/frankdejonge/status/1594966175108177921) ### Fixed - [PathPrefixing] ensure `checksum` and `temporaryUrl` are prefixed - [WebDAV] ensure directory creation uses trailing slashes for paths ## 3.10.3 - 2022-11-14 ### Fixed - [Local] Handle checksum errors without message (#1590) ## 3.10.2 - 2022-10-25 ### Fixed - [Filesystem] Ensure adapter is used for exposing temporary URLs. ## 3.10.1 - 2022-10-21 ### Fixed - [Filesystem] Added missing constructor argument to allow temporary URL generator injection. ## 3.10.0 - 2022-10-21 ### Added - [Filesystem] added `temporaryUrl` method - [AsyncAWS] added `temporaryUrl` method - [AWS S3] added `temporaryUrl` method - [Azure Blob Storage] added `temporaryUrl` method - [MountManager] added `temporaryUrl` method - [Google Cloud Storage] added `temporaryUrl` method - [ReadOnly] added `temporaryUrl` method - [PathPrefixing] added `temporaryUrl` method ## 3.9.0 - 2022-10-18 ### Added - [Filesystem] Added ability to inject custom public URL generator into a filesystem. - [MountManager] added `checksum` and `publicUrl` methods - [ZipArchive] Do not prefix directories when creating/reading an archive - [ShardedPrefixPublicUrlGenerator] added url generator strategy that distributes over a list of prefixes ## 3.8.0 - 2022-10-18 ### Added - [ChecksumAlgoIsNotSupported] Exception to indicate a checksum is not supported by the checksum provider, filesystem will fall back to ad-hoc generation. ## 3.7.0 - 2022-10-17 ### Added - [Filesystem] added `checksum` method - [AWS S3] added `checksum` method - [Async S3] added `checksum` method - [Google Cloud Storage] added `checksum` method - [Azure Blob Storage] added `checksum` method ## 3.6.0 - 2022-10-13 ### Added - [Filesystem] Added public url method - [Azure Blob Storage] Added public url method - [AWS S3] Added public url method - [Async S3] Added public url method - [GCS] Added public url method - [WebDAV] Added public url method - [ReadOnly] Added public url method - [PathPrefixing] Added public url method ## 3.5.3 - 2022-09-23 ### Fixed - [SFTP] Account for missing "type" field in metadata result. ## 3.5.2 - 2022-09-23 ### Fixed - [SFTP v2/v3] Fixed possible race-condition during directory creation leading to false failures. ## 3.5.1 - 2022-09-18 ### Fixed - [WebDAV] Strip directory prefix in `createDirectory` to prevent double prefixing in `directoryExists`. ## 3.5.0 - 2022-09-17 ### Added - [AWS S3] Allow specifying visibility on move and copy ## 3.4.0 - 2022-09-15 ### Added - Added FTP configuration option useRawListOptions (null|false|true). - UnableToListContents exception was added to uniformly represent content listing exceptions. ### Fixed - [FTP] Don't use raw list options for FileZilla FTP servers ([#1553](https://github.com/thephpleague/flysystem/pull/1553)) - [WebDAV] Correct path formatting for move and copy operations ([#1552](https://github.com/thephpleague/flysystem/pull/1552)) ## 3.3.0 - 2022-09-09 ### Added - StaticInMemoryAdapterRegistry contributed by @kbond - ReadonlyFilesystemAdapter contributed by @kbond - PathPrefixedAdapter contributed by @shyim ### Fixed - WebDAV prefix is now encoded and the dir is not required to be pre-created ([#1533](https://github.com/thephpleague/flysystem/pull/1533)) ## 3.2.1 - 2022-08-14 ### Fixed - [ZipArchive] reverted regression introduced in [#1525](https://github.com/thephpleague/flysystem/pull/1525) ## 3.2.0 - 2022-07-26 ### Added - [AwsS3V3] Added configuration options for forwarded options, multipart upload configuration, and metadata fields. ### Fixes - [ZipArchive] delete top-level directory when deleting directories. - [AwsS3V3] add `ChecksumAlgorithm` to forwarded options. - [AwsS3V3] add `ContentMD5` to forwarded options. - [AwsS3V3] made forwarded options and metadata fields configurable. - [SftpV3] upgrade minimum version, PHP 8 and the lowest version fails to authenticate. ## 3.1.1 - 2022-07-18 - [AwsS3V3] Corrected exception type (#1524) ## 3.1.0 - 2022-06-29 - Added option for the Local adapter to create the root directory only on the first mutating (write/copy/move) action. ## 3.0.23 - 2022-06-29 - Added reasons for exceptions for all adapters that were missing previous exception messages. ## 3.0.22 - 2022-06-29 - [AwsS3V3] Added reasons for exceptions - [AwsS3V3] Use ListObjectsV2 instead of ListObjects ## 3.0.21 - 2022-06-12 - [AwsS3V3] Use ListObjectsV2 instead of ListObjects ## 3.0.20 - 2022-05-25 ### Fixed - [Core] Fix deprecated ${var} string interpolation patterns (#1470) ## 3.0.19 - 2022-05-03 ### Fixed - [FTP] Turn errors into proper exceptions when resolving the connection root (#1460) ## 3.0.18 - 2022-04-25 ### Fixed - [SFTP v3] Fix retries (#1451) ## 3.0.17 - 2022-04-14 ### Fixed - [SFTP v2] Avoid type errors when public key is not retrieved (#1446) - [SFTP v3] Avoid type errors when public key is not retrieved (#1446) ## 3.0.16 - 2022-04-11 ### Fixed - [Local] fall back to extension lookups when the mime-type comes up as inconclusive. ## 3.0.15 - 2022-04-08 ### Fixed - [GCS] Allow setting upload metadata - [GCS] Allow setting contentType, or resolve it - [SFTP v2+v3] Delete top-level directory too. ## 3.0.14 - 2022-04-06 ### Added - [InMemory] allow to set a last-updated time (#1438) - [SFTP V3] allow configuring preferred algo's (#1440) ## 3.0.13 - 2022-04-02 ### Fixed - [AWS S3 V3] Do not return top-level directory when listing that same directory ## 3.0.12 - 2022-03-12 ### Fixed - [SFTP V3] Fix issue where listing is false. - [Async AWS S3] Cosmetic fix for directory prefixing. ## 3.0.11 - 2022-03-04 ### Fixed - [AWS S3] Use globally configured options. ## 3.0.10 - 2022-02-26 ### Fixed - [AWS S3] fix detecting directories that only contain other directories but no files. - [AWS S3] when checking for directory existence, limit the result set (perf) - [AWS S3] throw interface exception when failing to delete directory - [Async AWS S3] when checking for directory existence, limit the result set (perf) ## 3.0.9 - 2022-02-22 ### Fixed - [AWS S3] support setting an ACL as a direct option instead of using visibility. ## 3.0.8 - 2022-02-16 ### Fixed - [AWS S3] Set ContentType when mime-type config option is set during writes, like in v1. ## 3.0.7 - 2022-02-14 ### Fixed - [WebDAV] added missing composer.json for sub-split ## 3.0.6 - 2022-02-14 ### Added - [WebDAV] new adapter added ### Fixed - [Core] Trim slashed uniformly in the attribute classes. - [Core] Uniformly use directory_visibility over visibility for directory usage. - [FTP] export-ignore the test case. ## 3.0.5 - 2022-02-12 ### Added - [AzureBlobStorage] New adapter added ### Fixed - [AsyncAwsS3] Make EXTRA_METADATA_FIELDS protected to prevent error when extending the class ## 3.0.4 - 2022-02-10 ### Fixed - [FTP] Do not require setting of the root directory, use '' by default. ## 3.0.3 - 2022-01-31 ### Fixed - [FTP] Made connection resolving lazy again (#1414) ## 3.0.2 - 2022-01-30 ### Fixes * [FTP] Support relative or empty connection root directories (#1410) ## 3.0.1 - 2022-01-15 ### Fixes * [ZipArchive] delete top-level directory too when deleting a directory * [GoogleCloudStorage] Use listing to check for directory existence (consistency) * [GoogleCloudStorage] Fixed bug where exceptions were not thrown * [AwsS3V3] Allow passing options for controlling multi-upload options (#1396) * [Local] Convert windows-style directory separator to unix-style (#1398) ## 3.0.0 - 2022-01-13 ### Added * FilesystemReader::has to check for directory or file existence * FilesystemReader::directoryExists to check for directory existence * FilesystemReader::fileExists to check for file existence * FilesystemAdapter::directoryExists to check for directory existence * FilesystemAdapter::fileExists to check for file existence ## 2.5.0 - 2022-09-17 ### Added - [AWS S3] Allow specifying visibility on move and copy ## 2.4.5 - 2022-04-25 - [SFTP v3] Fix retries (#1451) ## 2.4.4 - 2022-04-14 ### Fixed - [SFTP v2] Avoid type errors when public key is not retrieved (#1446) - [SFTP v3] Avoid type errors when public key is not retrieved (#1446) ## 2.4.3 - 2022-02-16 ### Fixed - [AWS S3] Set ContentType when mime-type config option is set during writes, like in v1. ## 2.4.2 - 2022-01-31 ### Fixed - [FTP] Made connection resolving lazy again (#1414) ## 2.4.1 - 2022-01-30 ### Fixed - [FTP] Fix relative connection root handling ## 2.4.0 - 2022-01-04 ### Added - [SFTP V3] New adapter officially published ## 2.3.2 - 2021-11-28 ### Fixed - [FTP] Check for FTP\Connection object in addition to a `resource` for connectivity checks and connection handling. - [Local] Simplify writeStream, as a bonus, have an EXT_LOCK on it now by default. ## 2.3.1 - 2021-09-22 ### Fixed - [ZipArchive] copy stream, the ziparchive is closed after getting the stream - [Core] PHP 8.1 compatibility updates - [LocalFilesystem] parse permissions during listing - [LocalFilesystem] clear realstatcache - [FTP] PHP 8.1 compatibility updates - [Core] Upgraded PHP-CS-Fixer ## 2.3.0 - 2021-09-22 ### Added - [GoogleCloudStorage] Make it possible to set an empty prefix (#1358) - [GoogleCloudStorage] Added possibility not to set visibility (#1356) ## 2.2.3 - 2021-08-18 ### Fixed - [Core] Corrected exception message for UnableToCopyFile. ## 2.2.2 - 2021-08-18 ### Fixed - [Core] Ensure the sorted directory listing can be iterated over (#1342). ## 2.2.1 - 2021-08-17 ### Fixed - [FTP] use original path when ensuring the parent directory exists during `move` operation. - [FTP] do not fail setting UTF-8 when the server is already on UTF-8. ## 2.2.0 - 2021-07-20 ### Added * [Core] Added sortByPath on the directory listing to allows content listings to be sorted. ## 2.1.1 - 2021-06-24 ### Fixed * [Core] Whitespace normalization now no longer strips funky whitespace but throws an exception. ## 2.1.0 - 2021-05-25 ### Added * [Core] the DirectoryAttributes now have an `extraMetadata` like files do. ### Fixed * [AwsS3V3] Allow the ACL config option to take precedence over the visibility key. ## 2.0.8 - 2021-05-15 ### Fixed * [SFTP] Don't fail listing contents when a directory does not exist (#1301) ## 2.0.7 - 2021-05-13 ### Fixed * [LocalFilesystem] convert windows style paths to unix style paths on listing ## 2.0.6 - 2021-05-13 ### Fixed * [AsyncAwsS3] do not urlencode CopySource arguments (#1302) ## 2.0.5 - 2021-04-11 ### Fixed * [AwsS3] ensure write errors are turned into exceptions. ## 2.0.4 - 2021-02-13 ### Fixed * [InMemory] Corrected how the file size is determined. ## 2.0.3 - 2021-02-09 ### Fixed * [AwsS3V3] Use the $config array during the copy operation. * [Ftp] Close FTP connections when the object is destructed. * [Core] Allow for an absolute root path of `/`. ## 2.0.2 - 2020-12-28 ### Fixed * Corrected the ignored exports for Ftp ## 2.0.1 - 2020-12-28 ### Fixed * Corrected the ignored exports for Phpseclib ## 2.0.0 - 2020-11-24 ### Changed - string type added to all visibility input ### Added - Google Cloud Storage adapter. ## 2.0.0-beta.3 - 2020-08-23 ### Added - UnableToCheckFileExistence error introduced - Mount manager is re-introduced ### Fixes - Allow FTP filenames to contain special characters. - Prevent resources of incorrect types to be passed. ### Improved - [AWS] By default, make sure readStream resources are streamed over HTTP. ### Added - DirectoryAttributes now have a `lastModified` accessor. ## 2.0.0-beta.2 - 2020-08-08 ### Fixes - Allow listing of top-level directory for AWS S3 - Ensure the adapters can use the correct beta release. ## 2.0.0-beta.1 - 2020-08-04 ### Changes - Small code optimizations - Add global options array to AwsS3V3Adapter like in V1 ## 2.0.0-alpha.4 - 2020-07-26 ### Changes * Renamed AwsS3V3Filesystem to AwsS3V3Adapter (in line with other adapter names). * Renamed the PHPSecLibV2 package to PhpseclibV2, Renamed the FTP package to Ftp. * Public key and ss-agent authentication support for Sftp ### Fixes * Allow creation of files with empty streams. ## 2.0.0-alpha.3 - 2020-03-21 ### Fixes * Corrected the required version for the sub-split packages. ## 2.0.0-alpha.2 - 2020-03-17 ### Changes * The `League\Flysystem\FilesystemAdapter::listContents` method returns an `iterable` instead of a `Generator`. * The `League\Flysystem\DirectoryListing` class accepts an `iterable` instead of a `Generator`. ## 2.0.0-alpha.1 - 2020-03-09 * Initial 2.0.0 alpha release ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at conduct@flysystem.io. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: INFO.md ================================================ View the docs at: https://flysystem.thephpleague.com/docs/ Changelog at: https://github.com/thephpleague/flysystem/blob/3.x/CHANGELOG.md ================================================ FILE: LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: bin/.gitignore ================================================ renamespace.php ================================================ FILE: bin/check-versions.php ================================================ parseConstraints($mainConstraint); $mainLowerBound = $mainConstraint->getLowerBound()->getVersion(); $mainUpperBound = $mainConstraint->getUpperBound()->getVersion(); $packageConstraint = $parser->parseConstraints($packageConstraint); $packageLowerBound = $packageConstraint->getLowerBound()->getVersion(); $packageUpperBound = $packageConstraint->getUpperBound()->getVersion(); if (Comparator::compare($mainUpperBound, '<=', $packageLowerBound)) { return true; } if (Comparator::compare($packageUpperBound, '<=', $mainLowerBound)) { return true; } return false; } if ( ! isset($argv[1])) { panic('No base version provided'); } write_line("🔎 Inspecting composer dependency incompatibilities."); $mainVersion = $argv[1]; $filesystem = new Filesystem(new LocalFilesystemAdapter(__DIR__ . '/../')); $mainComposer = $filesystem->read('composer.json'); /** @var string[] $otherComposers */ $otherComposers = $filesystem->listContents('src', true) ->filter(function (StorageAttributes $item) { return $item->isFile(); }) ->filter(function (FileAttributes $item) { return substr($item->path(), -5) === '.json'; }) ->map(function (FileAttributes $item) { return $item->path(); }) ->toArray(); $mainInformation = json_decode($mainComposer, true); foreach ($otherComposers as $composerFile) { $information = json_decode($filesystem->read($composerFile), true); foreach ($information['require'] as $dependency => $constraint) { if (str_starts_with($dependency, 'ext-') || $dependency === 'phpseclib/phpseclib') { continue; } if ($dependency === 'league/flysystem') { if ( ! Semver::satisfies($mainVersion, $constraint)) { panic("Composer file {$composerFile} does not allow league/flysystem:{$mainVersion}"); } else { write_line("Composer file {$composerFile} allows league/flysystem:{$mainVersion} with {$constraint}"); } continue; } $mainDependencyConstraint = $mainInformation['require'][$dependency] ?? $mainInformation['require-dev'][$dependency] ?? null; if ( ! is_string($mainDependencyConstraint)) { panic( "The main composer file does not depend on an adapter dependency.\n" . "Depedency {$dependency} from {$composerFile} is missing." ); } if (constraint_has_conflict($mainDependencyConstraint, $constraint)) { panic( "Package constraints are conflicting:\n\n" . "Package composer file: {$composerFile}\n" . "Dependency name: {$dependency}\n" . "Main constraint: {$mainDependencyConstraint}\n" . "Package constraint: {$constraint}" ); } } } write_line("✅ Composer dependencies are looking fine."); ================================================ FILE: bin/close-subsplit-prs.yml ================================================ --- name: Close sub-split PRs on: push: branches: - 2.x - 3.x pull_request: branches: - 2.x - 3.x schedule: - cron: '30 7 * * *' jobs: close_subsplit_prs: runs-on: ubuntu-latest name: Close sub-split PRs steps: - uses: frankdejonge/action-close-subsplit-pr@0.1.0 with: close_pr: 'yes' target_branch_match: '^(?!master).+$' message: | Hi :wave:, Thank you for contributing to Flysystem. Unfortunately, you've sent a PR to a read-only sub-split repository. All pull requests should be directed towards: https://github.com/thephpleague/flysystem ================================================ FILE: bin/set-flysystem-version.php ================================================ listContents('src', true) ->filter(function (StorageAttributes $item) { return $item->isFile(); }) ->filter(function (FileAttributes $item) { return substr($item->path(), -5) === '.json'; }) ->map(function (FileAttributes $item) { return $item->path(); }) ->toArray(); foreach ($composerFiles as $composerFile) { $contents = $filesystem->read($composerFile); $mainVersionRegex = preg_quote($mainVersion, '~'); $updated = preg_replace('~("league/flysystem": "\\^[a-zA-Z0-9\\.-]+")~ms', '"league/flysystem": "^' . $mainVersion . '"', $contents); $filesystem->write($composerFile, $updated); } ================================================ FILE: bin/tools.php ================================================ read('config.subsplit-publish.json'), true); $workflowContents = $filesystem->read('bin/close-subsplit-prs.yml'); foreach ($subsplits['sub-splits'] as ['directory' => $subsplit]) { $workflowPath = $subsplit . '/.github/workflows/close-subsplit-prs.yaml'; $filesystem->write($workflowPath, $workflowContents); } ================================================ FILE: composer.json ================================================ { "name": "league/flysystem", "description": "File storage abstraction for PHP", "keywords": [ "filesystem", "filesystems", "files", "storage", "aws", "s3", "ftp", "sftp", "webdav", "file", "cloud" ], "scripts": { "phpstan": "vendor/bin/phpstan analyse -l 6 src" }, "type": "library", "minimum-stability": "dev", "prefer-stable": true, "autoload": { "psr-4": { "League\\Flysystem\\": "src" } }, "require": { "php": "^8.0.2", "league/flysystem-local": "^3.0.0", "league/mime-type-detection": "^1.0.0" }, "require-dev": { "ext-zip": "*", "ext-fileinfo": "*", "ext-ftp": "*", "ext-mongodb": "^1.3|^2", "microsoft/azure-storage-blob": "^1.1", "phpunit/phpunit": "^9.5.11|^10.0", "phpstan/phpstan": "^1.10", "phpseclib/phpseclib": "^3.0.36", "aws/aws-sdk-php": "^3.295.10", "composer/semver": "^3.0", "friendsofphp/php-cs-fixer": "^3.5", "google/cloud-storage": "^1.23", "async-aws/s3": "^1.5 || ^2.0", "async-aws/simple-s3": "^1.1 || ^2.0", "mongodb/mongodb": "^1.2|^2", "sabre/dav": "^4.6.0", "guzzlehttp/psr7": "^2.6" }, "conflict": { "async-aws/core": "<1.19.0", "async-aws/s3": "<1.14.0", "symfony/http-client": "<5.2", "guzzlehttp/ringphp": "<1.1.1", "guzzlehttp/guzzle": "<7.0", "aws/aws-sdk-php": "3.209.31 || 3.210.0", "phpseclib/phpseclib": "3.0.15" }, "license": "MIT", "authors": [ { "name": "Frank de Jonge", "email": "info@frankdejonge.nl" } ], "repositories": [ { "type": "package", "package": { "name": "league/flysystem-local", "version": "3.0.0", "dist": { "type": "path", "url": "src/Local" } } } ] } ================================================ FILE: config.subsplit-publish.json ================================================ { "sub-splits": [ { "name": "ftp", "directory": "src/Ftp", "target": "git@github.com:thephpleague/flysystem-ftp.git" }, { "name": "sftp", "directory": "src/PhpseclibV2", "target": "git@github.com:thephpleague/flysystem-sftp.git" }, { "name": "sftp-v3", "directory": "src/PhpseclibV3", "target": "git@github.com:thephpleague/flysystem-sftp-v3.git" }, { "name": "memory", "directory": "src/InMemory", "target": "git@github.com:thephpleague/flysystem-memory.git" }, { "name": "local", "directory": "src/Local", "target": "git@github.com:thephpleague/flysystem-local.git" }, { "name": "ziparchive", "directory": "src/ZipArchive", "target": "git@github.com:thephpleague/flysystem-ziparchive.git" }, { "name": "aws-s3-v3", "directory": "src/AwsS3V3", "target": "git@github.com:thephpleague/flysystem-aws-s3-v3.git" }, { "name": "async-aws-s3", "directory": "src/AsyncAwsS3", "target": "git@github.com:thephpleague/flysystem-async-aws-s3.git" }, { "name": "azure-blob-storage", "directory": "src/AzureBlobStorage", "target": "git@github.com:thephpleague/flysystem-azure-blob-storage.git" }, { "name": "google-cloud-storage", "directory": "src/GoogleCloudStorage", "target": "git@github.com:thephpleague/flysystem-google-cloud-storage.git" }, { "name": "readonly", "directory": "src/ReadOnly", "target": "git@github.com:thephpleague/flysystem-read-only.git" }, { "name": "pathprefixing", "directory": "src/PathPrefixing", "target": "git@github.com:thephpleague/flysystem-path-prefixing.git" }, { "name": "webdav", "directory": "src/WebDAV", "target": "git@github.com:thephpleague/flysystem-webdav.git" }, { "name": "adapter-test-utilities", "directory": "src/AdapterTestUtilities", "target": "git@github.com:thephpleague/flysystem-adapter-test-utilities.git" }, { "name": "gridfs", "directory": "src/GridFS", "target": "git@github.com:thephpleague/flysystem-gridfs.git" } ] } ================================================ FILE: docker-compose.yml ================================================ --- version: "3" services: mongodb: image: mongo:7 ports: - "27017:27017" sabredav: image: php:8.1-alpine3.15 restart: always volumes: - ./:/var/www/html/ ports: - "4040:4040" command: php -S 0.0.0.0:4040 /var/www/html/src/WebDAV/resources/server.php webdav: image: bytemark/webdav restart: always ports: - "4080:80" environment: AUTH_TYPE: Digest USERNAME: alice PASSWORD: secret1234 ANONYMOUS_METHODS: 'GET,OPTIONS' sftp: container_name: sftp restart: always image: atmoz/sftp volumes: - ./test_files/sftp/users.conf:/etc/sftp/users.conf - ./test_files/sftp/ssh_host_ed25519_key:/etc/ssh/ssh_host_ed25519_key - ./test_files/sftp/ssh_host_rsa_key:/etc/ssh/ssh_host_rsa_key - ./test_files/sftp/id_rsa.pub:/home/bar/.ssh/keys/id_rsa.pub ports: - "2222:22" sftp_eddsa_only: container_name: sftp_eddsa_only restart: always image: atmoz/sftp volumes: - ./test_files/sftp/users.conf:/etc/sftp/users.conf - ./test_files/sftp/sshd_custom_configs.sh:/etc/sftp.d/sshd_custom_configs.sh - ./test_files/sftp/ssh_host_ed25519_key:/etc/ssh/ssh_host_ed25519_key ports: - "2223:22" ftp: container_name: ftp restart: always image: delfer/alpine-ftp-server environment: USERS: 'foo|pass|/home/foo/upload' ADDRESS: 'localhost' ports: - "2121:21" - "21000-21010:21000-21010" ftpd: container_name: ftpd restart: always environment: PUBLICHOST: localhost FTP_USER_NAME: foo FTP_USER_PASS: pass FTP_USER_HOME: /home/foo image: stilliard/pure-ftpd ports: - "2122:21" - "30000-30009:30000-30009" command: "/run.sh -l puredb:/etc/pure-ftpd/pureftpd.pdb -E -j -P localhost" toxiproxy: container_name: toxiproxy restart: unless-stopped image: ghcr.io/shopify/toxiproxy command: "-host 0.0.0.0 -config /opt/toxiproxy/config.json" volumes: - ./test_files/toxiproxy/toxiproxy.json:/opt/toxiproxy/config.json:ro ports: - "8474:8474" # HTTP API - "8222:8222" # SFTP - "8121:8121" # FTP - "8122:8122" # FTPD ================================================ FILE: mocked-functions.php ================================================ src/ legacy src ================================================ FILE: readme.md ================================================ # League\Flysystem [![Author](https://img.shields.io/badge/author-@frankdejonge-blue.svg)](https://twitter.com/frankdejonge) [![Source Code](https://img.shields.io/badge/source-thephpleague/flysystem-blue.svg)](https://github.com/thephpleague/flysystem) [![Latest Version](https://img.shields.io/github/tag/thephpleague/flysystem.svg)](https://github.com/thephpleague/flysystem/releases) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](https://github.com/thephpleague/flysystem/blob/master/LICENSE) [![Quality Assurance](https://github.com/thephpleague/flysystem/workflows/Quality%20Assurance/badge.svg?branch=2.x)](https://github.com/thephpleague/flysystem/actions?query=workflow%3A%22Quality+Assurance%22) [![Total Downloads](https://img.shields.io/packagist/dt/league/flysystem.svg)](https://packagist.org/packages/league/flysystem) ![php 7.2+](https://img.shields.io/badge/php-min%208.0.2-red.svg) ## About Flysystem Flysystem is a file storage library for PHP. It provides one interface to interact with many types of filesystems. When you use Flysystem, you're not only protected from vendor lock-in, you'll also have a consistent experience for which ever storage is right for you. ## Getting Started * **[New in V3](https://flysystem.thephpleague.com/docs/what-is-new/)**: What is new in Flysystem V2/V3? * **[Architecture](https://flysystem.thephpleague.com/docs/architecture/)**: Flysystem's internal architecture * **[Flysystem API](https://flysystem.thephpleague.com/docs/usage/filesystem-api/)**: How to interact with your Flysystem instance * **[Upgrade from 1x](https://flysystem.thephpleague.com/docs/upgrade-from-1.x/)**: How to upgrade from 1.x/2.x ### Officially supported adapters * **[Local](https://flysystem.thephpleague.com/docs/adapter/local/)** * **[FTP](https://flysystem.thephpleague.com/docs/adapter/ftp/)** * **[SFTP](https://flysystem.thephpleague.com/docs/adapter/sftp-v3/)** * **[Memory](https://flysystem.thephpleague.com/docs/adapter/in-memory/)** * **[AWS S3](https://flysystem.thephpleague.com/docs/adapter/aws-s3-v3/)** * **[AsyncAws S3](https://flysystem.thephpleague.com/docs/adapter/async-aws-s3/)** * **[Google Cloud Storage](https://flysystem.thephpleague.com/docs/adapter/google-cloud-storage/)** * **[MongoDB GridFS](https://flysystem.thephpleague.com/docs/adapter/gridfs/)** * **[WebDAV](https://flysystem.thephpleague.com/docs/adapter/webdav/)** * **[ZipArchive](https://flysystem.thephpleague.com/docs/adapter/zip-archive/)** ### Third party Adapters * **[Azure Blob Storage](https://github.com/Azure-OSS/azure-storage-php-adapter-flysystem)** * **[Gitlab](https://github.com/RoyVoetman/flysystem-gitlab-storage)** * **[Google Drive (using regular paths)](https://github.com/masbug/flysystem-google-drive-ext)** * **[bunny.net / BunnyCDN](https://github.com/PlatformCommunity/flysystem-bunnycdn/tree/v3)** * **[Sharepoint 365 / One Drive (Using MS Graph)](https://github.com/shitware-ltd/flysystem-msgraph)** * **[OneDrive](https://github.com/doerffler/flysystem-onedrive)** * **[Dropbox](https://github.com/spatie/flysystem-dropbox)** * **[ReplicateAdapter](https://github.com/ajgarlag/flysystem-replicate)** * **[Uploadcare](https://github.com/vormkracht10/flysystem-uploadcare)** * **[Useful adapters (FallbackAdapter, LogAdapter, ReadWriteAdapter, RetryAdapter)](https://github.com/ElGigi/FlysystemUsefulAdapters)** * **[Metadata Cache](https://github.com/jgivoni/flysystem-cache-adapter)** * **[Migration adapter (lazy)](https://github.com/antonsacred/flysystem-lazy-migration-adapter)** You can always [create an adapter](https://flysystem.thephpleague.com/docs/advanced/creating-an-adapter/) yourself. ## Security If you discover any security related issues, please email info@frankdejonge.nl instead of using the issue tracker. ## Enjoy Oh, and if you've come down this far, you might as well follow me on [twitter](https://twitter.com/frankdejonge). ================================================ FILE: src/AdapterTestUtilities/.gitattributes ================================================ * text=auto .github export-ignore .gitattributes export-ignore README.md export-ignore ================================================ FILE: src/AdapterTestUtilities/.github/workflows/close-subsplit-prs.yaml ================================================ --- name: Close sub-split PRs on: push: branches: - 2.x - 3.x pull_request: branches: - 2.x - 3.x schedule: - cron: '30 7 * * *' jobs: close_subsplit_prs: runs-on: ubuntu-latest name: Close sub-split PRs steps: - uses: frankdejonge/action-close-subsplit-pr@0.1.0 with: close_pr: 'yes' target_branch_match: '^(?!master).+$' message: | Hi :wave:, Thank you for contributing to Flysystem. Unfortunately, you've sent a PR to a read-only sub-split repository. All pull requests should be directed towards: https://github.com/thephpleague/flysystem ================================================ FILE: src/AdapterTestUtilities/ExceptionThrowingFilesystemAdapter.php ================================================ */ private $stagedExceptions = []; public function __construct(FilesystemAdapter $adapter) { $this->adapter = $adapter; } public function stageException(string $method, string $path, FilesystemOperationFailed $exception): void { $this->stagedExceptions[join('@', [$method, $path])] = $exception; } private function throwStagedException(string $method, $path): void { $method = preg_replace('~.+::~', '', $method); $key = join('@', [$method, $path]); if ( ! array_key_exists($key, $this->stagedExceptions)) { return; } $exception = $this->stagedExceptions[$key]; unset($this->stagedExceptions[$key]); throw $exception; } public function fileExists(string $path): bool { $this->throwStagedException(__METHOD__, $path); return $this->adapter->fileExists($path); } public function write(string $path, string $contents, Config $config): void { $this->throwStagedException(__METHOD__, $path); $this->adapter->write($path, $contents, $config); } public function writeStream(string $path, $contents, Config $config): void { $this->throwStagedException(__METHOD__, $path); $this->adapter->writeStream($path, $contents, $config); } public function read(string $path): string { $this->throwStagedException(__METHOD__, $path); return $this->adapter->read($path); } public function readStream(string $path) { $this->throwStagedException(__METHOD__, $path); return $this->adapter->readStream($path); } public function delete(string $path): void { $this->throwStagedException(__METHOD__, $path); $this->adapter->delete($path); } public function deleteDirectory(string $path): void { $this->throwStagedException(__METHOD__, $path); $this->adapter->deleteDirectory($path); } public function createDirectory(string $path, Config $config): void { $this->throwStagedException(__METHOD__, $path); $this->adapter->createDirectory($path, $config); } public function setVisibility(string $path, string $visibility): void { $this->throwStagedException(__METHOD__, $path); $this->adapter->setVisibility($path, $visibility); } public function visibility(string $path): FileAttributes { $this->throwStagedException(__METHOD__, $path); return $this->adapter->visibility($path); } public function mimeType(string $path): FileAttributes { $this->throwStagedException(__METHOD__, $path); return $this->adapter->mimeType($path); } public function lastModified(string $path): FileAttributes { $this->throwStagedException(__METHOD__, $path); return $this->adapter->lastModified($path); } public function fileSize(string $path): FileAttributes { $this->throwStagedException(__METHOD__, $path); return $this->adapter->fileSize($path); } public function listContents(string $path, bool $deep): iterable { $this->throwStagedException(__METHOD__, $path); return $this->adapter->listContents($path, $deep); } public function move(string $source, string $destination, Config $config): void { $this->throwStagedException(__METHOD__, $source); $this->adapter->move($source, $destination, $config); } public function copy(string $source, string $destination, Config $config): void { $this->throwStagedException(__METHOD__, $source); $this->adapter->copy($source, $destination, $config); } public function directoryExists(string $path): bool { $this->throwStagedException(__METHOD__, $path); return $this->adapter->directoryExists($path); } } ================================================ FILE: src/AdapterTestUtilities/FilesystemAdapterTestCase.php ================================================ adapter(); } protected function useAdapter(FilesystemAdapter $adapter): FilesystemAdapter { static::$adapter = $adapter; $this->isUsingCustomAdapter = true; return $adapter; } /** * @after */ public function cleanupAdapter(): void { $this->clearCustomAdapter(); $this->clearStorage(); } public function clearStorage(): void { reset_function_mocks(); try { $adapter = $this->adapter(); } catch (Throwable $exception) { /* * Setting up the filesystem adapter failed. This is OK at this stage. * The exception will have been shown to the user when trying to run * a test. We expect an exception to be thrown when tests are marked as * skipped when a filesystem adapter cannot be constructed. */ return; } $this->runSetup(function () use ($adapter) { /** @var StorageAttributes $item */ foreach ($adapter->listContents('', false) as $item) { if ($item->isDir()) { $adapter->deleteDirectory($item->path()); } else { $adapter->delete($item->path()); } } }); } public function clearCustomAdapter(): void { if ($this->isUsingCustomAdapter) { $this->isUsingCustomAdapter = false; self::clearFilesystemAdapterCache(); } } /** * @test */ public function writing_and_reading_with_string(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write('path.txt', 'contents', new Config()); $fileExists = $adapter->fileExists('path.txt'); $contents = $adapter->read('path.txt'); $this->assertTrue($fileExists); $this->assertEquals('contents', $contents); }); } /** * @test */ public function writing_a_file_with_a_stream(): void { $this->runScenario(function () { $adapter = $this->adapter(); $writeStream = stream_with_contents('contents'); $adapter->writeStream('path.txt', $writeStream, new Config([ Config::OPTION_VISIBILITY => Visibility::PUBLIC, ])); if (is_resource($writeStream)) { fclose($writeStream); } $fileExists = $adapter->fileExists('path.txt'); $this->assertTrue($fileExists); }); } /** * @test * * @dataProvider filenameProvider */ public function writing_and_reading_files_with_special_path(string $path): void { $this->runScenario(function () use ($path) { $adapter = $this->adapter(); $adapter->write($path, 'contents', new Config()); $contents = $adapter->read($path); $this->assertEquals('contents', $contents); }); } public static function filenameProvider(): Generator { yield "a path with square brackets in filename 1" => ["some/file[name].txt"]; yield "a path with square brackets in filename 2" => ["some/file[0].txt"]; yield "a path with square brackets in filename 3" => ["some/file[10].txt"]; yield "a path with square brackets in dirname 1" => ["some[name]/file.txt"]; yield "a path with square brackets in dirname 2" => ["some[0]/file.txt"]; yield "a path with square brackets in dirname 3" => ["some[10]/file.txt"]; yield "a path with curly brackets in filename 1" => ["some/file{name}.txt"]; yield "a path with curly brackets in filename 2" => ["some/file{0}.txt"]; yield "a path with curly brackets in filename 3" => ["some/file{10}.txt"]; yield "a path with curly brackets in dirname 1" => ["some{name}/filename.txt"]; yield "a path with curly brackets in dirname 2" => ["some{0}/filename.txt"]; yield "a path with curly brackets in dirname 3" => ["some{10}/filename.txt"]; yield "a path with space in dirname" => ["some dir/filename.txt"]; yield "a path with space in filename" => ["somedir/file name.txt"]; } /** * @test */ public function writing_a_file_with_an_empty_stream(): void { $this->runScenario(function () { $adapter = $this->adapter(); $writeStream = stream_with_contents(''); $adapter->writeStream('path.txt', $writeStream, new Config()); if (is_resource($writeStream)) { fclose($writeStream); } $fileExists = $adapter->fileExists('path.txt'); $this->assertTrue($fileExists); $contents = $adapter->read('path.txt'); $this->assertEquals('', $contents); }); } /** * @test */ public function listing_a_directory_named_0(): void { $this->givenWeHaveAnExistingFile('0/path.txt'); $this->givenWeHaveAnExistingFile('1/path.txt'); $this->runScenario(function () { $listing = iterator_to_array($this->adapter()->listContents('0', false)); $this->assertCount(1, $listing); }); } /** * @test */ public function reading_a_file(): void { $this->givenWeHaveAnExistingFile('path.txt', 'contents'); $this->runScenario(function () { $contents = $this->adapter()->read('path.txt'); $this->assertEquals('contents', $contents); }); } /** * @test */ public function reading_a_file_with_a_stream(): void { $this->givenWeHaveAnExistingFile('path.txt', 'contents'); $this->runScenario(function () { $readStream = $this->adapter()->readStream('path.txt'); $contents = stream_get_contents($readStream); $this->assertIsResource($readStream); $this->assertEquals('contents', $contents); fclose($readStream); }); } /** * @test */ public function overwriting_a_file(): void { $this->runScenario(function () { $this->givenWeHaveAnExistingFile('path.txt', 'contents', ['visibility' => Visibility::PUBLIC]); $adapter = $this->adapter(); $adapter->write('path.txt', 'new contents', new Config(['visibility' => Visibility::PRIVATE])); $contents = $adapter->read('path.txt'); $this->assertEquals('new contents', $contents); $visibility = $adapter->visibility('path.txt')->visibility(); $this->assertEquals(Visibility::PRIVATE, $visibility); }); } /** * @test */ public function a_file_exists_only_when_it_is_written_and_not_deleted(): void { $this->runScenario(function () { $adapter = $this->adapter(); // does not exist before creation self::assertFalse($adapter->fileExists('path.txt')); // a file exists after creation $this->givenWeHaveAnExistingFile('path.txt'); self::assertTrue($adapter->fileExists('path.txt')); // a file no longer exists after creation $adapter->delete('path.txt'); self::assertFalse($adapter->fileExists('path.txt')); }); } /** * @test */ public function listing_contents_shallow(): void { $this->runScenario(function () { $this->givenWeHaveAnExistingFile('some/0-path.txt', 'contents'); $this->givenWeHaveAnExistingFile('some/1-nested/path.txt', 'contents'); $listing = $this->adapter()->listContents('some', false); /** @var StorageAttributes[] $items */ $items = iterator_to_array($listing); $this->assertInstanceOf(Generator::class, $listing); $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $items); $this->assertCount(2, $items, $this->formatIncorrectListingCount($items)); // Order of entries is not guaranteed [$fileIndex, $directoryIndex] = $items[0]->isFile() ? [0, 1] : [1, 0]; $this->assertEquals('some/0-path.txt', $items[$fileIndex]->path()); $this->assertEquals('some/1-nested', $items[$directoryIndex]->path()); $this->assertTrue($items[$fileIndex]->isFile()); $this->assertTrue($items[$directoryIndex]->isDir()); }); } /** * @test */ public function checking_if_a_non_existing_directory_exists(): void { $this->runScenario(function () { $adapter = $this->adapter(); self::assertFalse($adapter->directoryExists('this-does-not-exist.php')); }); } /** * @test */ public function checking_if_a_directory_exists_after_writing_a_file(): void { $this->runScenario(function () { $adapter = $this->adapter(); $this->givenWeHaveAnExistingFile('existing-directory/file.txt'); self::assertTrue($adapter->directoryExists('existing-directory')); }); } /** * @test */ public function checking_if_a_directory_exists_after_creating_it(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->createDirectory('explicitly-created-directory', new Config()); self::assertTrue($adapter->directoryExists('explicitly-created-directory')); $adapter->deleteDirectory('explicitly-created-directory'); $l = iterator_to_array($adapter->listContents('/', false), false); self::assertEquals([], $l); self::assertFalse($adapter->directoryExists('explicitly-created-directory')); }); } /** * @test */ public function listing_contents_recursive(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->createDirectory('path', new Config()); $adapter->write('path/file.txt', 'string', new Config()); $listing = $adapter->listContents('', true); /** @var StorageAttributes[] $items */ $items = iterator_to_array($listing); $this->assertCount(2, $items, $this->formatIncorrectListingCount($items)); }); } protected function formatIncorrectListingCount(array $items): string { $message = "Incorrect number of items returned.\nThe listing contains:\n\n"; /** @var StorageAttributes $item */ foreach ($items as $item) { $message .= "- {$item->path()}\n"; } return $message . PHP_EOL; } protected function givenWeHaveAnExistingFile(string $path, string $contents = 'contents', array $config = []): void { $this->runSetup(function () use ($path, $contents, $config) { $this->adapter()->write($path, $contents, new Config($config)); }); } /** * @test */ public function fetching_file_size(): void { $adapter = $this->adapter(); $this->givenWeHaveAnExistingFile('path.txt', 'contents'); $this->runScenario(function () use ($adapter) { $attributes = $adapter->fileSize('path.txt'); $this->assertInstanceOf(FileAttributes::class, $attributes); $this->assertEquals(8, $attributes->fileSize()); }); } /** * @test */ public function setting_visibility(): void { $this->runScenario(function () { $adapter = $this->adapter(); $this->givenWeHaveAnExistingFile('path.txt', 'contents', [Config::OPTION_VISIBILITY => Visibility::PUBLIC]); $this->assertEquals(Visibility::PUBLIC, $adapter->visibility('path.txt')->visibility()); $adapter->setVisibility('path.txt', Visibility::PRIVATE); $this->assertEquals(Visibility::PRIVATE, $adapter->visibility('path.txt')->visibility()); $adapter->setVisibility('path.txt', Visibility::PUBLIC); $this->assertEquals(Visibility::PUBLIC, $adapter->visibility('path.txt')->visibility()); }); } /** * @test */ public function fetching_file_size_of_a_directory(): void { $this->expectException(UnableToRetrieveMetadata::class); $adapter = $this->adapter(); $this->runScenario(function () use ($adapter) { $adapter->createDirectory('path', new Config()); $adapter->fileSize('path/'); }); } /** * @test */ public function fetching_file_size_of_non_existing_file(): void { $this->expectException(UnableToRetrieveMetadata::class); $this->runScenario(function () { $this->adapter()->fileSize('non-existing-file.txt'); }); } /** * @test */ public function fetching_last_modified_of_non_existing_file(): void { $this->expectException(UnableToRetrieveMetadata::class); $this->runScenario(function () { $this->adapter()->lastModified('non-existing-file.txt'); }); } /** * @test */ public function fetching_visibility_of_non_existing_file(): void { $this->expectException(UnableToRetrieveMetadata::class); $this->runScenario(function () { $this->adapter()->visibility('non-existing-file.txt'); }); } /** * @test */ public function fetching_the_mime_type_of_an_svg_file(): void { $this->runScenario(function () { $this->givenWeHaveAnExistingFile('file.svg', file_get_contents(__DIR__ . '/test_files/flysystem.svg')); $mimetype = $this->adapter()->mimeType('file.svg')->mimeType(); $this->assertStringStartsWith('image/svg+xml', $mimetype); }); } /** * @test */ public function fetching_mime_type_of_non_existing_file(): void { $this->expectException(UnableToRetrieveMetadata::class); $this->runScenario(function () { $this->adapter()->mimeType('non-existing-file.txt'); }); } /** * @test */ public function fetching_unknown_mime_type_of_a_file(): void { $this->givenWeHaveAnExistingFile( 'unknown-mime-type.md5', file_get_contents(__DIR__ . '/test_files/unknown-mime-type.md5') ); $this->expectException(UnableToRetrieveMetadata::class); $this->runScenario(function () { $this->adapter()->mimeType('unknown-mime-type.md5'); }); } /** * @test */ public function listing_a_toplevel_directory(): void { $this->givenWeHaveAnExistingFile('path1.txt'); $this->givenWeHaveAnExistingFile('path2.txt'); $this->runScenario(function () { $contents = iterator_to_array($this->adapter()->listContents('', true)); $this->assertCount(2, $contents); }); } /** * @test */ public function writing_and_reading_with_streams(): void { $this->runScenario(function () { $writeStream = stream_with_contents('contents'); $adapter = $this->adapter(); $adapter->writeStream('path.txt', $writeStream, new Config()); if (is_resource($writeStream)) { fclose($writeStream); }; $readStream = $adapter->readStream('path.txt'); $this->assertIsResource($readStream); $contents = stream_get_contents($readStream); fclose($readStream); $this->assertEquals('contents', $contents); }); } /** * @test */ public function setting_visibility_on_a_file_that_does_not_exist(): void { $this->expectException(UnableToSetVisibility::class); $this->runScenario(function () { $this->adapter()->setVisibility('this-path-does-not-exists.txt', Visibility::PRIVATE); }); } /** * @test */ public function copying_a_file(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->copy('source.txt', 'destination.txt', new Config()); $this->assertTrue($adapter->fileExists('source.txt')); $this->assertTrue($adapter->fileExists('destination.txt')); $this->assertEquals(Visibility::PUBLIC, $adapter->visibility('destination.txt')->visibility()); $this->assertEquals('text/plain', $adapter->mimeType('destination.txt')->mimeType()); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function copying_a_file_that_does_not_exist(): void { $this->expectException(UnableToCopyFile::class); $this->runScenario(function () { $this->adapter()->copy('source.txt', 'destination.txt', new Config()); }); } /** * @test */ public function copying_a_file_again(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->copy('source.txt', 'destination.txt', new Config()); $this->assertTrue($adapter->fileExists('source.txt')); $this->assertTrue($adapter->fileExists('destination.txt')); $this->assertEquals(Visibility::PUBLIC, $adapter->visibility('destination.txt')->visibility()); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function moving_a_file(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->move('source.txt', 'destination.txt', new Config()); $this->assertFalse( $adapter->fileExists('source.txt'), 'After moving a file should no longer exist in the original location.' ); $this->assertTrue( $adapter->fileExists('destination.txt'), 'After moving, a file should be present at the new location.' ); $this->assertEquals(Visibility::PUBLIC, $adapter->visibility('destination.txt')->visibility()); $this->assertEquals('text/plain', $adapter->mimeType('destination.txt')->mimeType()); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function file_exists_on_directory_is_false(): void { $this->runScenario(function () { $adapter = $this->adapter(); $this->assertFalse($adapter->directoryExists('test')); $adapter->createDirectory('test', new Config()); $this->assertTrue($adapter->directoryExists('test')); $this->assertFalse($adapter->fileExists('test')); }); } /** * @test */ public function directory_exists_on_file_is_false(): void { $this->runScenario(function () { $adapter = $this->adapter(); $this->assertFalse($adapter->fileExists('test.txt')); $adapter->write('test.txt', 'content', new Config()); $this->assertTrue($adapter->fileExists('test.txt')); $this->assertFalse($adapter->directoryExists('test.txt')); }); } /** * @test */ public function reading_a_file_that_does_not_exist(): void { $this->expectException(UnableToReadFile::class); $this->runScenario(function () { $this->adapter()->read('path.txt'); }); } /** * @test */ public function moving_a_file_that_does_not_exist(): void { $this->expectException(UnableToMoveFile::class); $this->runScenario(function () { $this->adapter()->move('source.txt', 'destination.txt', new Config()); }); } /** * @test */ public function trying_to_delete_a_non_existing_file(): void { $adapter = $this->adapter(); $adapter->delete('path.txt'); $fileExists = $adapter->fileExists('path.txt'); $this->assertFalse($fileExists); } /** * @test */ public function checking_if_files_exist(): void { $this->runScenario(function () { $adapter = $this->adapter(); $fileExistsBefore = $adapter->fileExists('some/path.txt'); $adapter->write('some/path.txt', 'contents', new Config()); $fileExistsAfter = $adapter->fileExists('some/path.txt'); $this->assertFalse($fileExistsBefore); $this->assertTrue($fileExistsAfter); }); } /** * @test */ public function fetching_last_modified(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write('path.txt', 'contents', new Config()); $attributes = $adapter->lastModified('path.txt'); $this->assertInstanceOf(FileAttributes::class, $attributes); $this->assertIsInt($attributes->lastModified()); $this->assertTrue($attributes->lastModified() > time() - 30); $this->assertTrue($attributes->lastModified() < time() + 30); }); } /** * @test */ public function failing_to_read_a_non_existing_file_into_a_stream(): void { $this->expectException(UnableToReadFile::class); $this->adapter()->readStream('something.txt'); } /** * @test */ public function failing_to_read_a_non_existing_file(): void { $this->expectException(UnableToReadFile::class); $this->adapter()->read('something.txt'); } /** * @test */ public function creating_a_directory(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->createDirectory('creating_a_directory/path', new Config()); // Creating a directory should be idempotent. $adapter->createDirectory('creating_a_directory/path', new Config()); $contents = iterator_to_array($adapter->listContents('creating_a_directory', false)); $this->assertCount(1, $contents, $this->formatIncorrectListingCount($contents)); /** @var DirectoryAttributes $directory */ $directory = $contents[0]; $this->assertInstanceOf(DirectoryAttributes::class, $directory); $this->assertEquals('creating_a_directory/path', $directory->path()); $adapter->deleteDirectory('creating_a_directory/path'); }); } /** * @test */ public function copying_a_file_with_collision(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write('path.txt', 'new contents', new Config()); $adapter->write('new-path.txt', 'contents', new Config()); $adapter->copy('path.txt', 'new-path.txt', new Config()); $contents = $adapter->read('new-path.txt'); $this->assertEquals('new contents', $contents); }); } /** * @test */ public function moving_a_file_with_collision(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write('path.txt', 'new contents', new Config()); $adapter->write('new-path.txt', 'contents', new Config()); $adapter->move('path.txt', 'new-path.txt', new Config()); $oldFileExists = $adapter->fileExists('path.txt'); $this->assertFalse($oldFileExists); $contents = $adapter->read('new-path.txt'); $this->assertEquals('new contents', $contents); }); } /** * @test */ public function copying_a_file_with_same_destination(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write('path.txt', 'new contents', new Config()); $adapter->copy('path.txt', 'path.txt', new Config()); $contents = $adapter->read('path.txt'); $this->assertEquals('new contents', $contents); }); } /** * @test */ public function moving_a_file_with_same_destination(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write('path.txt', 'new contents', new Config()); $adapter->move('path.txt', 'path.txt', new Config()); $contents = $adapter->read('path.txt'); $this->assertEquals('new contents', $contents); }); } protected function assertFileExistsAtPath(string $path): void { $this->runScenario(function () use ($path) { $fileExists = $this->adapter()->fileExists($path); $this->assertTrue($fileExists); }); } /** * @test */ public function generating_a_public_url(): void { $adapter = $this->adapter(); if ( ! $adapter instanceof PublicUrlGenerator) { $this->markTestSkipped('Adapter does not supply public URls'); } $adapter->write('some/path.txt', 'public contents', new Config(['visibility' => 'public'])); $url = $adapter->publicUrl('some/path.txt', new Config()); $contents = file_get_contents($url); self::assertEquals('public contents', $contents); } /** * @test */ public function generating_a_temporary_url(): void { $adapter = $this->adapter(); if ( ! $adapter instanceof TemporaryUrlGenerator) { $this->markTestSkipped('Adapter does not supply temporary URls'); } $adapter->write('some/private.txt', 'public contents', new Config(['visibility' => 'private'])); $expiresAt = (new DateTimeImmutable())->add(DateInterval::createFromDateString('1 minute')); $url = $adapter->temporaryUrl('some/private.txt', $expiresAt, new Config()); $contents = file_get_contents($url); self::assertEquals('public contents', $contents); } /** * @test */ public function get_checksum(): void { $adapter = $this->adapter(); if ( ! $adapter instanceof ChecksumProvider) { $this->markTestSkipped('Adapter does not supply providing checksums'); } $adapter->write('path.txt', 'foobar', new Config()); $this->assertSame('3858f62230ac3c915f300c664312c63f', $adapter->checksum('path.txt', new Config())); } /** * @test */ public function cannot_get_checksum_for_non_existent_file(): void { $adapter = $this->adapter(); if ( ! $adapter instanceof ChecksumProvider) { $this->markTestSkipped('Adapter does not supply providing checksums'); } $this->expectException(UnableToProvideChecksum::class); $adapter->checksum('path.txt', new Config()); } /** * @test */ public function cannot_get_checksum_for_directory(): void { $adapter = $this->adapter(); if ( ! $adapter instanceof ChecksumProvider) { $this->markTestSkipped('Adapter does not supply providing checksums'); } $adapter->createDirectory('dir', new Config()); $this->expectException(UnableToProvideChecksum::class); $adapter->checksum('dir', new Config()); } } ================================================ FILE: src/AdapterTestUtilities/README.md ================================================ ## Flysystem Adapter Test Utilities > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem Require this package to make use of some adapter test utilities. ```bash composer require --dev league/flysystem-adapter-test-utilities ``` View the [documentation of Flysystem](https://flysystem.thephpleague.com/docs/). ================================================ FILE: src/AdapterTestUtilities/RetryOnTestException.php ================================================ exceptionTypeToRetryOn = $className; $this->timeoutForExceptionRetry = $timout; } protected function retryScenarioOnException(string $className, callable $scenario, int $timeout = 2): void { $this->retryOnException($className, $timeout); $this->runScenario($scenario); } protected function dontRetryOnException(): void { $this->exceptionTypeToRetryOn = null; } /** * @internal * * @throws Throwable */ protected function runSetup(callable $scenario): void { $previousException = $this->exceptionTypeToRetryOn; $previousTimeout = $this->timeoutForExceptionRetry; $this->retryOnException(FilesystemException::class); try { $this->runScenario($scenario); } finally { $this->exceptionTypeToRetryOn = $previousException; $this->timeoutForExceptionRetry = $previousTimeout; } } protected function runScenario(callable $scenario): void { if ($this->exceptionTypeToRetryOn === null) { $scenario(); return; } $firstTryAt = \time(); $lastTryAt = $firstTryAt + 60; while (time() <= $lastTryAt) { try { $scenario(); return; } catch (Throwable $exception) { if ( ! $exception instanceof $this->exceptionTypeToRetryOn) { throw $exception; } fwrite(STDOUT, 'Retrying ...' . PHP_EOL); sleep($this->timeoutForExceptionRetry); } } $this->exceptionTypeToRetryOn = null; if (isset($exception) && $exception instanceof Throwable) { throw $exception; } } } ================================================ FILE: src/AdapterTestUtilities/ToxiproxyManagement.php ================================================ apiClient = $apiClient; } public static function forServer(string $apiUri = 'http://localhost:8474'): self { return new self( new Client( [ 'base_uri' => $apiUri, 'base_url' => $apiUri, // Compatibility with older versions of Guzzle ] ) ); } public function removeAllToxics(): void { $this->apiClient->post('/reset'); } /** * Simulates a peer reset on the client->server direction. * * @param RegisteredProxies $proxyName */ public function resetPeerOnRequest( string $proxyName, int $timeoutInMilliseconds ): void { $configuration = [ 'type' => 'reset_peer', 'stream' => 'upstream', 'attributes' => ['timeout' => $timeoutInMilliseconds], ]; $this->addToxic($proxyName, $configuration); } /** * Registers a network toxic for the given proxy. * * @param RegisteredProxies $proxyName * @param Toxic $configuration */ private function addToxic(string $proxyName, array $configuration): void { $this->apiClient->post('/proxies/' . $proxyName . '/toxics', ['json' => $configuration]); } } ================================================ FILE: src/AdapterTestUtilities/composer.json ================================================ { "name": "league/flysystem-adapter-test-utilities", "description": "Flysystem utilities for testing adapters.", "keywords": ["filesystem", "flysystem", "adapter", "test", "utilities"], "minimum-stability": "dev", "prefer-stable": true, "autoload": { "psr-4": { "League\\Flysystem\\AdapterTestUtilities\\": "" }, "files": [ "test-functions.php" ] }, "require": { "php": "^8.0.2", "league/flysystem": "^3.0.0" }, "license": "MIT", "authors": [ { "name": "Frank de Jonge", "email": "info@frankdejonge.nl" } ] } ================================================ FILE: src/AdapterTestUtilities/test-functions.php ================================================ prefixer = new PathPrefixer($prefix); $this->visibility = $visibility ?? new PortableVisibilityConverter(); $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); $this->forwardedOptions = $forwardedOptions; $this->metadataFields = $metadataFields; } public function fileExists(string $path): bool { try { return $this->client->objectExists( [ 'Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path), ] )->isSuccess(); } catch (ClientException $e) { throw UnableToCheckFileExistence::forLocation($path, $e); } } public function write(string $path, string $contents, Config $config): void { $this->upload($path, $contents, $config); } public function writeStream(string $path, $contents, Config $config): void { $this->upload($path, $contents, $config); } public function read(string $path): string { $body = $this->readObject($path); return $body->getContentAsString(); } public function readStream(string $path) { $body = $this->readObject($path); return $body->getContentAsResource(); } public function delete(string $path): void { $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; try { $this->client->deleteObject($arguments); } catch (Throwable $exception) { throw UnableToDeleteFile::atLocation($path, '', $exception); } } public function deleteDirectory(string $path): void { $prefix = $this->prefixer->prefixDirectoryPath($path); $prefix = ltrim($prefix, '/'); $objects = []; $params = ['Bucket' => $this->bucket, 'Prefix' => $prefix]; try { $result = $this->client->listObjectsV2($params); /** @var AwsObject $item */ foreach ($result->getContents() as $item) { $key = $item->getKey(); if (null !== $key) { $objects[] = $this->createObjectIdentifierForXmlRequest($key); } } if (empty($objects)) { return; } foreach (array_chunk($objects, 1000) as $chunk) { $this->client->deleteObjects([ 'Bucket' => $this->bucket, 'Delete' => ['Objects' => $chunk], ]); } } catch (\Throwable $e) { throw UnableToDeleteDirectory::atLocation($path, $e->getMessage(), $e); } } public function createDirectory(string $path, Config $config): void { $defaultVisibility = $config->get(Config::OPTION_DIRECTORY_VISIBILITY, $this->visibility->defaultForDirectories()); $config = $config->withDefaults([Config::OPTION_VISIBILITY => $defaultVisibility]); try { $this->upload(rtrim($path, '/') . '/', '', $config); } catch (Throwable $e) { throw UnableToCreateDirectory::dueToFailure($path, $e); } } public function setVisibility(string $path, string $visibility): void { $arguments = [ 'Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path), 'ACL' => $this->visibility->visibilityToAcl($visibility), ]; try { $this->client->putObjectAcl($arguments); } catch (Throwable $exception) { throw UnableToSetVisibility::atLocation($path, $exception->getMessage(), $exception); } } public function visibility(string $path): FileAttributes { $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; try { $result = $this->client->getObjectAcl($arguments); $grants = $result->getGrants(); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::visibility($path, $exception->getMessage(), $exception); } $visibility = $this->visibility->aclToVisibility($grants); return new FileAttributes($path, null, $visibility); } public function mimeType(string $path): FileAttributes { $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_MIME_TYPE); if (null === $attributes->mimeType()) { throw UnableToRetrieveMetadata::mimeType($path); } return $attributes; } public function lastModified(string $path): FileAttributes { $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_LAST_MODIFIED); if (null === $attributes->lastModified()) { throw UnableToRetrieveMetadata::lastModified($path); } return $attributes; } public function fileSize(string $path): FileAttributes { $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE); if (null === $attributes->fileSize()) { throw UnableToRetrieveMetadata::fileSize($path); } return $attributes; } public function directoryExists(string $path): bool { try { $prefix = $this->prefixer->prefixDirectoryPath($path); $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix, 'MaxKeys' => 1, 'Delimiter' => '/']; return $this->client->listObjectsV2($options)->getKeyCount() > 0; } catch (Throwable $exception) { throw UnableToCheckDirectoryExistence::forLocation($path, $exception); } } public function listContents(string $path, bool $deep): iterable { $path = trim($path, '/'); $prefix = trim($this->prefixer->prefixPath($path), '/'); $prefix = $prefix === '' ? '' : $prefix . '/'; $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix]; if (false === $deep) { $options['Delimiter'] = '/'; } try { $listing = $this->retrievePaginatedListing($options); foreach ($listing as $item) { $item = $this->mapS3ObjectMetadata($item); if ($item->path() === $path) { continue; } yield $item; } } catch (\Throwable $e) { throw UnableToListContents::atLocation($path, $deep, $e); } } public function move(string $source, string $destination, Config $config): void { if ($source === $destination) { return; } try { $this->copy($source, $destination, $config); $this->delete($source); } catch (Throwable $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } } public function copy(string $source, string $destination, Config $config): void { if ($source === $destination) { return; } try { $visibility = $config->get(Config::OPTION_VISIBILITY); if ($visibility === null && $config->get(Config::OPTION_RETAIN_VISIBILITY, true)) { $visibility = $this->visibility($source)->visibility(); } } catch (Throwable $exception) { throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } $arguments = [ 'ACL' => $this->visibility->visibilityToAcl($visibility ?: 'private'), 'Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($destination), 'CopySource' => rawurlencode($this->bucket . '/' . $this->prefixer->prefixPath($source)), ]; try { $this->client->copyObject($arguments); } catch (Throwable $exception) { throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } /** * @param string|resource $body */ private function upload(string $path, $body, Config $config): void { $key = $this->prefixer->prefixPath($path); $acl = $this->determineAcl($config); $options = $this->createOptionsFromConfig($config); $shouldDetermineMimetype = '' !== $body && ! \array_key_exists('ContentType', $options); if ($shouldDetermineMimetype && $mimeType = $this->mimeTypeDetector->detectMimeType($key, $body)) { $options['ContentType'] = $mimeType; } try { if ($this->client instanceof SimpleS3Client) { // Supports upload of files larger than 5GB $this->client->upload($this->bucket, $key, $body, array_merge($options, ['ACL' => $acl])); } else { $this->client->putObject(array_merge($options, [ 'Bucket' => $this->bucket, 'Key' => $key, 'Body' => $body, 'ACL' => $acl, ])); } } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } private function determineAcl(Config $config): string { $visibility = (string) $config->get(Config::OPTION_VISIBILITY, Visibility::PRIVATE); return $this->visibility->visibilityToAcl($visibility); } private function createOptionsFromConfig(Config $config): array { $options = []; foreach ($this->forwardedOptions as $option) { $value = $config->get($option, '__NOT_SET__'); if ('__NOT_SET__' !== $value) { $options[$option] = $value; } } return $options; } private function fetchFileMetadata(string $path, string $type): FileAttributes { $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; try { $result = $this->client->headObject($arguments); $result->resolve(); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::create($path, $type, $exception->getMessage(), $exception); } $attributes = $this->mapS3ObjectMetadata($result, $path); if ( ! $attributes instanceof FileAttributes) { throw UnableToRetrieveMetadata::create($path, $type, 'Unable to retrieve file attributes, directory attributes received.'); } return $attributes; } /** * @param HeadObjectOutput|AwsObject|CommonPrefix $item */ private function mapS3ObjectMetadata($item, ?string $path = null): StorageAttributes { if (null === $path) { if ($item instanceof AwsObject) { $path = $this->prefixer->stripPrefix($item->getKey() ?? ''); } elseif ($item instanceof CommonPrefix) { $path = $this->prefixer->stripPrefix($item->getPrefix() ?? ''); } else { throw new \RuntimeException(sprintf('Argument 2 of "%s" cannot be null when $item is not instance of "%s" or %s', __METHOD__, AwsObject::class, CommonPrefix::class)); } } if ('/' === substr($path, -1)) { return new DirectoryAttributes(rtrim($path, '/')); } $mimeType = null; $fileSize = null; $lastModified = null; $dateTime = null; $metadata = []; if ($item instanceof AwsObject) { $dateTime = $item->getLastModified(); $fileSize = $item->getSize(); } elseif ($item instanceof CommonPrefix) { // No data available } elseif ($item instanceof HeadObjectOutput) { $mimeType = $item->getContentType(); $fileSize = $item->getContentLength(); $dateTime = $item->getLastModified(); $metadata = $this->extractExtraMetadata($item); } else { throw new \RuntimeException(sprintf('Object of class "%s" is not supported in %s()', \get_class($item), __METHOD__)); } if ($dateTime instanceof \DateTimeInterface) { $lastModified = $dateTime->getTimestamp(); } return new FileAttributes($path, $fileSize !== null ? (int) $fileSize : null, null, $lastModified, $mimeType, $metadata); } /** * @param HeadObjectOutput $metadata */ private function extractExtraMetadata($metadata): array { $extracted = []; foreach ($this->metadataFields as $field) { $method = 'get' . $field; if ( ! method_exists($metadata, $method)) { continue; } $value = $metadata->$method(); if (null !== $value) { $extracted[$field] = $value; } } return $extracted; } private function retrievePaginatedListing(array $options): Generator { $result = $this->client->listObjectsV2($options); foreach ($result as $item) { yield $item; } } private function readObject(string $path): ResultStream { $options = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; try { return $this->client->getObject($options)->getBody(); } catch (Throwable $exception) { throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); } } private function createObjectIdentifierForXmlRequest(string $key): ObjectIdentifier { $escapedKey = htmlentities($key, ENT_XML1 | ENT_QUOTES, 'UTF-8'); if ($escapedKey === '') { throw new \RuntimeException(sprintf('Cannot escape key "%s" for XML request, htmlentities() returned an empty string.', $key)); } return new ObjectIdentifier(['Key' => $escapedKey]); } public function publicUrl(string $path, Config $config): string { if ( ! $this->client instanceof SimpleS3Client) { throw UnableToGeneratePublicUrl::noGeneratorConfigured($path, 'Client needs to be instance of SimpleS3Client'); } try { return $this->client->getUrl($this->bucket, $this->prefixer->prefixPath($path)); } catch (Throwable $exception) { throw UnableToGeneratePublicUrl::dueToError($path, $exception); } } public function checksum(string $path, Config $config): string { $algo = $config->get('checksum_algo', 'etag'); if ($algo !== 'etag') { throw new ChecksumAlgoIsNotSupported(); } try { $metadata = $this->fetchFileMetadata($path, 'checksum')->extraMetadata(); } catch (UnableToRetrieveMetadata $exception) { throw new UnableToProvideChecksum($exception->reason(), $path, $exception); } if ( ! isset($metadata['ETag'])) { throw new UnableToProvideChecksum('ETag header not available.', $path); } return trim($metadata['ETag'], '"'); } public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string { try { $request = new GetObjectRequest([ 'Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path), ] + $config->get('get_object_options', [])); return $this->client->presign($request, DateTimeImmutable::createFromInterface($expiresAt)); } catch (Throwable $exception) { throw UnableToGenerateTemporaryUrl::dueToError($path, $exception); } } } ================================================ FILE: src/AsyncAwsS3/AsyncAwsS3AdapterTest.php ================================================ $key, 'accessKeySecret' => $secret, 'region' => $region, ]; } protected function setUp(): void { parent::setUp(); $this->retryOnException(NetworkException::class); } public static function setUpBeforeClass(): void { static::$adapterPrefix = 'ci/' . bin2hex(random_bytes(10)); } protected function tearDown(): void { if ( ! $this->shouldCleanUp) { return; } $adapter = $this->adapter(); $adapter->deleteDirectory('/'); /** @var StorageAttributes[] $listing */ $listing = $adapter->listContents('', false); foreach ($listing as $item) { if ($item->isFile()) { $adapter->delete($item->path()); } else { $adapter->deleteDirectory($item->path()); } } } private static function s3Client(): S3Client { if (static::$s3Client instanceof S3Client) { return static::$s3Client; } $bucket = getenv('FLYSYSTEM_AWS_S3_BUCKET'); if ( ! $bucket) { self::markTestSkipped('No AWS credentials present for testing.'); } static::$s3Client = new SimpleS3Client(self::awsConfig()); return static::$s3Client; } /** * @test */ public function specifying_a_custom_checksum_algo_is_not_supported(): void { /** @var AwsS3V3Adapter $adapter */ $adapter = $this->adapter(); $this->expectException(ChecksumAlgoIsNotSupported::class); $adapter->checksum('something', new Config(['checksum_algo' => 'md5'])); } /** * @test * * @see https://github.com/thephpleague/flysystem-aws-s3-v3/issues/287 */ public function issue_287(): void { $adapter = $this->adapter(); $adapter->write('KmFVvKqo/QLMExy2U/620ff60c8a154.pdf', 'pdf content', new Config()); self::assertTrue($adapter->directoryExists('KmFVvKqo')); } /** * @test */ public function writing_with_a_specific_mime_type(): void { $adapter = $this->adapter(); $adapter->write('some/path.txt', 'contents', new Config(['ContentType' => 'text/plain+special'])); $mimeType = $adapter->mimeType('some/path.txt')->mimeType(); $this->assertEquals('text/plain+special', $mimeType); } /** * @test */ public function listing_contents_recursive(): void { $adapter = $this->adapter(); $adapter->write('something/0/here.txt', 'contents', new Config()); $adapter->write('something/1/also/here.txt', 'contents', new Config()); $contents = iterator_to_array($adapter->listContents('', true)); $this->assertCount(2, $contents); $this->assertContainsOnlyInstancesOf(FileAttributes::class, $contents); /** @var FileAttributes $file */ $file = $contents[0]; $this->assertEquals('something/0/here.txt', $file->path()); /** @var FileAttributes $file */ $file = $contents[1]; $this->assertEquals('something/1/also/here.txt', $file->path()); } /** * @test */ public function failing_to_delete_while_moving(): void { $adapter = $this->adapter(); $adapter->write('source.txt', 'contents to be copied', new Config()); static::$stubS3Client->throwExceptionWhenExecutingCommand('CopyObject'); $this->expectException(UnableToMoveFile::class); $adapter->move('source.txt', 'destination.txt', new Config()); } /** * @test */ public function failing_to_delete_a_file(): void { $adapter = $this->adapter(); static::$stubS3Client->throwExceptionWhenExecutingCommand('DeleteObject'); $this->expectException(UnableToDeleteFile::class); $adapter->delete('path.txt'); } /** * @test */ public function delete_directory_replaces_special_characters_by_xml_entity_codes(): void { $this->runScenario(function () { $directory = 'to-delete'; $object = sprintf('/%s/\'\"&<>.txt', $directory); $adapter = $this->adapter(); $adapter->write( $object, '', new Config() ); $adapter->deleteDirectory($directory); $this->assertFalse($adapter->fileExists($object)); $this->assertFalse($adapter->directoryExists($directory)); }); } /** * @test */ public function delete_directory_throws_exception_if_object_key_can_not_be_escaped_correctly(): void { $listObjectsMock = $this->getMockBuilder(ListObjectsV2Output::class) ->disableOriginalConstructor() ->onlyMethods(['getContents']) ->getMock(); $listObjectsMock->expects(self::once()) ->method('getContents') ->willReturn([new AwsObject(['Key' => "\x8F.txt"])]); $s3Client = $this->getMockBuilder(S3Client::class) ->disableOriginalConstructor() ->onlyMethods(['ListObjectsV2']) ->getMock(); $s3Client->expects(self::once()) ->method('ListObjectsV2') ->willReturn($listObjectsMock); $filesystem = new AsyncAwsS3Adapter($s3Client, 'my-bucket'); $this->expectException(UnableToDeleteDirectory::class); $this->expectExceptionMessageMatches('/htmlentities\(\) returned an empty string/'); $filesystem->deleteDirectory('directory/containing/objects/with/un-escapable/key'); } /** * @test */ public function fetching_unknown_mime_type_of_a_file(): void { $this->adapter(); $result = ResultMockFactory::create(HeadObjectOutput::class, []); static::$stubS3Client->stageResultForCommand('HeadObject', $result); parent::fetching_unknown_mime_type_of_a_file(); } /** * @test * * @dataProvider dpFailingMetadataGetters */ public function failing_to_retrieve_metadata(Exception $exception, string $getterName): void { $adapter = $this->adapter(); $result = ResultMockFactory::create(HeadObjectOutput::class, []); static::$stubS3Client->stageResultForCommand('HeadObject', $result); $this->expectExceptionObject($exception); $adapter->{$getterName}('filename.txt'); } public static function dpFailingMetadataGetters(): iterable { yield "mimeType" => [UnableToRetrieveMetadata::mimeType('filename.txt'), 'mimeType']; yield "lastModified" => [UnableToRetrieveMetadata::lastModified('filename.txt'), 'lastModified']; yield "fileSize" => [UnableToRetrieveMetadata::fileSize('filename.txt'), 'fileSize']; } /** * @test */ public function failing_to_check_for_file_existence(): void { $adapter = $this->adapter(); $exception = new ClientException(new SimpleMockedResponse()); static::$stubS3Client->throwExceptionWhenExecutingCommand('ObjectExists', $exception); $this->expectException(UnableToCheckFileExistence::class); $adapter->fileExists('something-that-does-exist.txt'); } /** * @test */ public function configuring_http_streaming_via_options(): void { $adapter = $this->useAdapter($this->createFilesystemAdapter()); $this->givenWeHaveAnExistingFile('path.txt'); $resource = $adapter->readStream('path.txt'); $metadata = stream_get_meta_data($resource); fclose($resource); $this->assertTrue($metadata['seekable']); } /** * @test */ public function write_with_s3_client(): void { $file = 'foo/bar.txt'; $prefix = 'all-files'; $bucket = 'foobar'; $contents = 'contents'; $s3Client = $this->getMockBuilder(S3Client::class) ->disableOriginalConstructor() ->onlyMethods(['putObject']) ->getMock(); $s3Client->expects(self::once()) ->method('putObject') ->with(self::callback(function (array $input) use ($file, $prefix, $bucket, $contents) { if ($input['Key'] !== $prefix . '/' . $file) { return false; } if ($contents !== $input['Body']) { return false; } if ($input['Bucket'] !== $bucket) { return false; } return true; }))->willReturn(ResultMockFactory::create(PutObjectOutput::class)); $filesystem = new AsyncAwsS3Adapter($s3Client, $bucket, $prefix); $filesystem->write($file, $contents, new Config()); } /** * @test */ public function write_with_simple_s3_client(): void { $file = 'foo/bar.txt'; $prefix = 'all-files'; $bucket = 'foobar'; $contents = 'contents'; $s3Client = $this->getMockBuilder(SimpleS3Client::class) ->disableOriginalConstructor() ->onlyMethods(['upload', 'putObject']) ->getMock(); $s3Client->expects(self::never())->method('putObject'); $s3Client->expects(self::once()) ->method('upload') ->with($bucket, $prefix . '/' . $file, $contents); $filesystem = new AsyncAwsS3Adapter($s3Client, $bucket, $prefix); $filesystem->write($file, $contents, new Config()); } /** * @test */ public function failing_to_write_a_file(): void { $adapter = $this->adapter(); static::$stubS3Client->throwExceptionWhenExecutingCommand('PutObject'); $this->expectException(UnableToWriteFile::class); $adapter->write('foo/bar.txt', 'contents', new Config()); } /** * @test */ public function moving_a_file_with_visibility(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->move('source.txt', 'destination.txt', new Config([Config::OPTION_VISIBILITY => Visibility::PRIVATE])); $this->assertFalse( $adapter->fileExists('source.txt'), 'After moving a file should no longer exist in the original location.' ); $this->assertTrue( $adapter->fileExists('destination.txt'), 'After moving, a file should be present at the new location.' ); $this->assertEquals(Visibility::PRIVATE, $adapter->visibility('destination.txt')->visibility()); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function copying_a_file_with_visibility(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->copy('source.txt', 'destination.txt', new Config([Config::OPTION_VISIBILITY => Visibility::PRIVATE])); $this->assertTrue($adapter->fileExists('source.txt')); $this->assertTrue($adapter->fileExists('destination.txt')); $this->assertEquals(Visibility::PRIVATE, $adapter->visibility('destination.txt')->visibility()); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function copying_a_file_with_non_ascii_characters(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'ıÇöü🤔.txt', 'contents to be copied', new Config() ); $adapter->copy('ıÇöü🤔.txt', 'ıÇöü🤔_copy.txt', new Config()); $this->assertTrue($adapter->fileExists('ıÇöü🤔.txt')); $this->assertTrue($adapter->fileExists('ıÇöü🤔_copy.txt')); $this->assertEquals('contents to be copied', $adapter->read('ıÇöü🤔_copy.txt')); }); } /** * @test */ public function top_level_directory_excluded_from_listing(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write('directory/file.txt', '', new Config()); $adapter->createDirectory('empty', new Config()); $adapter->createDirectory('nested/nested', new Config()); $listing1 = iterator_to_array($adapter->listContents('directory', true)); $listing2 = iterator_to_array($adapter->listContents('empty', true)); $listing3 = iterator_to_array($adapter->listContents('nested', true)); self::assertCount(1, $listing1); self::assertCount(0, $listing2); self::assertCount(1, $listing3); }); } /** * @test */ public function failing_to_list_contents(): void { $adapter = $this->adapter(); static::$stubS3Client->throwExceptionWhenExecutingCommand('ListObjectsV2'); $this->expectException(UnableToListContents::class); iterator_to_array($adapter->listContents('/path', false)); } protected static function createFilesystemAdapter(): FilesystemAdapter { static::$stubS3Client = new S3ClientStub(static::s3Client(), self::awsConfig()); /** @var string $bucket */ $bucket = getenv('FLYSYSTEM_AWS_S3_BUCKET'); $prefix = getenv('FLYSYSTEM_AWS_S3_PREFIX') ?: static::$adapterPrefix; return new AsyncAwsS3Adapter(static::$stubS3Client, $bucket, $prefix, null, null); } } ================================================ FILE: src/AsyncAwsS3/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/AsyncAwsS3/PortableVisibilityConverter.php ================================================ defaultForDirectories = $defaultForDirectories; } public function visibilityToAcl(string $visibility): string { if (Visibility::PUBLIC === $visibility) { return self::PUBLIC_ACL; } return self::PRIVATE_ACL; } /** * @param Grant[] $grants */ public function aclToVisibility(array $grants): string { foreach ($grants as $grant) { if (null === $grantee = $grant->getGrantee()) { continue; } $granteeUri = $grantee->getURI(); $permission = $grant->getPermission(); if (self::PUBLIC_GRANTEE_URI === $granteeUri && self::PUBLIC_GRANTS_PERMISSION === $permission) { return Visibility::PUBLIC; } } return Visibility::PRIVATE; } public function defaultForDirectories(): string { return $this->defaultForDirectories; } } ================================================ FILE: src/AsyncAwsS3/README.md ================================================ ## Sub-split of Flysystem for AsyncAws S3. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-async-aws-s3 ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/async-aws-s3/) ================================================ FILE: src/AsyncAwsS3/S3ClientStub.php ================================================ actualClient = $client; parent::__construct($configuration, null, new MockHttpClient()); } public function throwExceptionWhenExecutingCommand(string $commandName, ?Exception $exception = null): void { $this->stagedExceptions[$commandName] = $exception ?? new NetworkException(); } public function stageResultForCommand(string $commandName, Result $result): void { $this->stagedResult[$commandName] = $result; } private function getStagedResult(string $name): ?Result { if (array_key_exists($name, $this->stagedExceptions)) { $exception = $this->stagedExceptions[$name]; unset($this->stagedExceptions[$name]); throw $exception; } if (array_key_exists($name, $this->stagedResult)) { $result = $this->stagedResult[$name]; unset($this->stagedResult[$name]); return $result; } return null; } /** * @param array|CopyObjectRequest $input */ public function copyObject($input): CopyObjectOutput { // @phpstan-ignore-next-line return $this->getStagedResult('CopyObject') ?? $this->actualClient->copyObject($input); } /** * @param array|DeleteObjectRequest $input */ public function deleteObject($input): DeleteObjectOutput { // @phpstan-ignore-next-line return $this->getStagedResult('DeleteObject') ?? $this->actualClient->deleteObject($input); } /** * @param array|HeadObjectRequest $input */ public function headObject($input): HeadObjectOutput { // @phpstan-ignore-next-line return $this->getStagedResult('HeadObject') ?? $this->actualClient->headObject($input); } /** * @param array|HeadObjectRequest $input */ public function objectExists($input): ObjectExistsWaiter { // @phpstan-ignore-next-line return $this->getStagedResult('ObjectExists') ?? $this->actualClient->objectExists($input); } /** * @param array|ListObjectsV2Request $input */ public function listObjectsV2($input): ListObjectsV2Output { // @phpstan-ignore-next-line return $this->getStagedResult('ListObjectsV2') ?? $this->actualClient->listObjectsV2($input); } /** * @param array|DeleteObjectsRequest $input */ public function deleteObjects($input): DeleteObjectsOutput { // @phpstan-ignore-next-line return $this->getStagedResult('DeleteObjects') ?? $this->actualClient->deleteObjects($input); } /** * @param array|GetObjectAclRequest $input */ public function getObjectAcl($input): GetObjectAclOutput { // @phpstan-ignore-next-line return $this->getStagedResult('GetObjectAcl') ?? $this->actualClient->getObjectAcl($input); } /** * @param array|PutObjectAclRequest $input */ public function putObjectAcl($input): PutObjectAclOutput { // @phpstan-ignore-next-line return $this->getStagedResult('PutObjectAcl') ?? $this->actualClient->putObjectAcl($input); } /** * @param array|PutObjectRequest $input */ public function putObject($input): PutObjectOutput { // @phpstan-ignore-next-line return $this->getStagedResult('PutObject') ?? $this->actualClient->putObject($input); } /** * @param array|GetObjectRequest $input */ public function getObject($input): GetObjectOutput { // @phpstan-ignore-next-line return $this->getStagedResult('GetObject') ?? $this->actualClient->getObject($input); } public function getUrl(string $bucket, string $key): string { return $this->actualClient->getUrl($bucket, $key); } public function getPresignedUrl(string $bucket, string $key, ?DateTimeImmutable $expires = null, ?string $versionId = null): string { return $this->actualClient->getPresignedUrl($bucket, $key, $expires); } } ================================================ FILE: src/AsyncAwsS3/VisibilityConverter.php ================================================ prefixer = new PathPrefixer($prefix); $this->visibility = $visibility ?? new PortableVisibilityConverter(); $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); } public function fileExists(string $path): bool { try { return $this->client->doesObjectExistV2($this->bucket, $this->prefixer->prefixPath($path), false, $this->options); } catch (Throwable $exception) { throw UnableToCheckFileExistence::forLocation($path, $exception); } } public function directoryExists(string $path): bool { try { $prefix = $this->prefixer->prefixDirectoryPath($path); $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix, 'MaxKeys' => 1, 'Delimiter' => '/']; $command = $this->client->getCommand('ListObjectsV2', $options); $result = $this->client->execute($command); return $result->hasKey('Contents') || $result->hasKey('CommonPrefixes'); } catch (Throwable $exception) { throw UnableToCheckDirectoryExistence::forLocation($path, $exception); } } public function write(string $path, string $contents, Config $config): void { $this->upload($path, $contents, $config); } /** * @param string $path * @param string|resource $body * @param Config $config */ private function upload(string $path, $body, Config $config): void { $key = $this->prefixer->prefixPath($path); $options = $this->createOptionsFromConfig($config); $acl = $options['params']['ACL'] ?? $this->determineAcl($config); $shouldDetermineMimetype = ! array_key_exists('ContentType', $options['params']); if ($shouldDetermineMimetype && $mimeType = $this->mimeTypeDetector->detectMimeType($key, $body)) { $options['params']['ContentType'] = $mimeType; } try { $this->client->upload($this->bucket, $key, $body, $acl, $options); } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } private function determineAcl(Config $config): string { $visibility = (string) $config->get(Config::OPTION_VISIBILITY, Visibility::PRIVATE); return $this->visibility->visibilityToAcl($visibility); } private function createOptionsFromConfig(Config $config): array { $config = $config->withDefaults($this->options); $options = ['params' => []]; if ($mimetype = $config->get('mimetype')) { $options['params']['ContentType'] = $mimetype; } foreach ($this->forwardedOptions as $option) { $value = $config->get($option, '__NOT_SET__'); if ($value !== '__NOT_SET__') { $options['params'][$option] = $value; } } foreach ($this->multipartUploadOptions as $option) { $value = $config->get($option, '__NOT_SET__'); if ($value !== '__NOT_SET__') { $options[$option] = $value; } } return $options; } public function writeStream(string $path, $contents, Config $config): void { $this->upload($path, $contents, $config); } public function read(string $path): string { $body = $this->readObject($path, false); return (string) $body->getContents(); } public function readStream(string $path) { /** @var resource $resource */ $resource = $this->readObject($path, true)->detach(); return $resource; } public function delete(string $path): void { $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; $command = $this->client->getCommand('DeleteObject', $arguments); try { $this->client->execute($command); } catch (Throwable $exception) { throw UnableToDeleteFile::atLocation($path, '', $exception); } } public function deleteDirectory(string $path): void { $prefix = $this->prefixer->prefixPath($path); $prefix = ltrim(rtrim($prefix, '/') . '/', '/'); try { $this->client->deleteMatchingObjects($this->bucket, $prefix); } catch (Throwable $exception) { throw UnableToDeleteDirectory::atLocation($path, '', $exception); } } public function createDirectory(string $path, Config $config): void { $defaultVisibility = $config->get(Config::OPTION_DIRECTORY_VISIBILITY, $this->visibility->defaultForDirectories()); $config = $config->withDefaults([Config::OPTION_VISIBILITY => $defaultVisibility]); $this->upload(rtrim($path, '/') . '/', '', $config); } public function setVisibility(string $path, string $visibility): void { $arguments = [ 'Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path), 'ACL' => $this->visibility->visibilityToAcl($visibility), ]; $command = $this->client->getCommand('PutObjectAcl', $arguments); try { $this->client->execute($command); } catch (Throwable $exception) { throw UnableToSetVisibility::atLocation($path, '', $exception); } } public function visibility(string $path): FileAttributes { $arguments = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; $command = $this->client->getCommand('GetObjectAcl', $arguments); try { $result = $this->client->execute($command); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::visibility($path, '', $exception); } $visibility = $this->visibility->aclToVisibility((array) $result->get('Grants')); return new FileAttributes($path, null, $visibility); } private function fetchFileMetadata(string $path, string $type): FileAttributes { $options = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; $command = $this->client->getCommand('HeadObject', $options + $this->options); try { $result = $this->client->execute($command); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::create($path, $type, '', $exception); } $attributes = $this->mapS3ObjectMetadata($result->toArray(), $path); if ( ! $attributes instanceof FileAttributes) { throw UnableToRetrieveMetadata::create($path, $type, ''); } return $attributes; } private function mapS3ObjectMetadata(array $metadata, string $path): StorageAttributes { if (substr($path, -1) === '/') { return new DirectoryAttributes(rtrim($path, '/')); } $mimetype = $metadata['ContentType'] ?? null; $fileSize = $metadata['ContentLength'] ?? $metadata['Size'] ?? null; $fileSize = $fileSize === null ? null : (int) $fileSize; $dateTime = $metadata['LastModified'] ?? null; $lastModified = $dateTime instanceof DateTimeResult ? $dateTime->getTimeStamp() : null; return new FileAttributes( $path, $fileSize, null, $lastModified, $mimetype, $this->extractExtraMetadata($metadata) ); } private function extractExtraMetadata(array $metadata): array { $extracted = []; foreach ($this->metadataFields as $field) { if (isset($metadata[$field]) && $metadata[$field] !== '') { $extracted[$field] = $metadata[$field]; } } return $extracted; } public function mimeType(string $path): FileAttributes { $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_MIME_TYPE); if ($attributes->mimeType() === null) { throw UnableToRetrieveMetadata::mimeType($path); } return $attributes; } public function lastModified(string $path): FileAttributes { $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_LAST_MODIFIED); if ($attributes->lastModified() === null) { throw UnableToRetrieveMetadata::lastModified($path); } return $attributes; } public function fileSize(string $path): FileAttributes { $attributes = $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE); if ($attributes->fileSize() === null) { throw UnableToRetrieveMetadata::fileSize($path); } return $attributes; } public function listContents(string $path, bool $deep): iterable { $prefix = trim($this->prefixer->prefixPath($path), '/'); $prefix = $prefix === '' ? '' : $prefix . '/'; $options = ['Bucket' => $this->bucket, 'Prefix' => $prefix]; if ($deep === false) { $options['Delimiter'] = '/'; } $listing = $this->retrievePaginatedListing($options); foreach ($listing as $item) { $key = $item['Key'] ?? $item['Prefix']; if ($key === $prefix) { continue; } yield $this->mapS3ObjectMetadata($item, $this->prefixer->stripPrefix($key)); } } private function retrievePaginatedListing(array $options): Generator { $resultPaginator = $this->client->getPaginator('ListObjectsV2', $options + $this->options); foreach ($resultPaginator as $result) { yield from ($result->get('CommonPrefixes') ?? []); yield from ($result->get('Contents') ?? []); } } public function move(string $source, string $destination, Config $config): void { if ($source === $destination) { return; } try { $this->copy($source, $destination, $config); $this->delete($source); } catch (FilesystemOperationFailed $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } } public function copy(string $source, string $destination, Config $config): void { if ($source === $destination) { return; } try { $visibility = $config->get(Config::OPTION_VISIBILITY); if ($visibility === null && $config->get(Config::OPTION_RETAIN_VISIBILITY, true)) { $visibility = $this->visibility($source)->visibility(); } } catch (Throwable $exception) { throw UnableToCopyFile::fromLocationTo( $source, $destination, $exception ); } $options = $this->createOptionsFromConfig($config); $options['MetadataDirective'] = $config->get('MetadataDirective', 'COPY'); try { $this->client->copy( $this->bucket, $this->prefixer->prefixPath($source), $this->bucket, $this->prefixer->prefixPath($destination), $this->visibility->visibilityToAcl($visibility ?: 'private'), $options, ); } catch (Throwable $exception) { throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } private function readObject(string $path, bool $wantsStream): StreamInterface { $options = ['Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path)]; if ($wantsStream && $this->streamReads && ! isset($this->options['@http']['stream'])) { $options['@http']['stream'] = true; } $command = $this->client->getCommand('GetObject', $options + $this->options); try { return $this->client->execute($command)->get('Body'); } catch (Throwable $exception) { throw UnableToReadFile::fromLocation($path, '', $exception); } } public function publicUrl(string $path, Config $config): string { $location = $this->prefixer->prefixPath($path); try { return $this->client->getObjectUrl($this->bucket, $location); } catch (Throwable $exception) { throw UnableToGeneratePublicUrl::dueToError($path, $exception); } } public function checksum(string $path, Config $config): string { $algo = $config->get('checksum_algo', 'etag'); if ($algo !== 'etag') { throw new ChecksumAlgoIsNotSupported(); } try { $metadata = $this->fetchFileMetadata($path, 'checksum')->extraMetadata(); } catch (UnableToRetrieveMetadata $exception) { throw new UnableToProvideChecksum($exception->reason(), $path, $exception); } if ( ! isset($metadata['ETag'])) { throw new UnableToProvideChecksum('ETag header not available.', $path); } return trim($metadata['ETag'], '"'); } public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string { try { $options = $config->get('get_object_options', []); $command = $this->client->getCommand('GetObject', [ 'Bucket' => $this->bucket, 'Key' => $this->prefixer->prefixPath($path), ] + $options); $presignedRequestOptions = $config->get('presigned_request_options', []); $request = $this->client->createPresignedRequest($command, $expiresAt, $presignedRequestOptions); return (string) $request->getUri(); } catch (Throwable $exception) { throw UnableToGenerateTemporaryUrl::dueToError($path, $exception); } } } ================================================ FILE: src/AwsS3V3/AwsS3V3AdapterTest.php ================================================ shouldCleanUp) { return; } $adapter = $this->adapter(); $adapter->deleteDirectory('/'); /** @var StorageAttributes[] $listing */ $listing = $adapter->listContents('', false); foreach ($listing as $item) { if ($item->isFile()) { $adapter->delete($item->path()); } else { $adapter->deleteDirectory($item->path()); } } self::$adapter = null; } protected function setUp(): void { if (PHP_VERSION_ID < 80100) { $this->markTestSkipped('AWS does not support this anymore.'); } parent::setUp(); } private static function s3Client(): S3ClientInterface { if (static::$s3Client instanceof S3ClientInterface) { return static::$s3Client; } $key = getenv('FLYSYSTEM_AWS_S3_KEY'); $secret = getenv('FLYSYSTEM_AWS_S3_SECRET'); $bucket = getenv('FLYSYSTEM_AWS_S3_BUCKET'); $region = getenv('FLYSYSTEM_AWS_S3_REGION') ?: 'eu-central-1'; if ( ! $key || ! $secret || ! $bucket) { self::markTestSkipped('No AWS credentials present for testing.'); } $options = ['version' => 'latest', 'credentials' => compact('key', 'secret'), 'region' => $region]; return static::$s3Client = new S3Client($options); } /** * @test */ public function writing_with_a_specific_mime_type(): void { $adapter = $this->adapter(); $adapter->write('some/path.txt', 'contents', new Config(['ContentType' => 'text/plain+special'])); $mimeType = $adapter->mimeType('some/path.txt')->mimeType(); $this->assertEquals('text/plain+special', $mimeType); } /** * @test */ public function writing_a_file_with_explicit_mime_type(): void { $adapter = $this->adapter(); $adapter->write('some/path.txt', 'contents', new Config(['mimetype' => 'text/plain+special'])); $mimeType = $adapter->mimeType('some/path.txt')->mimeType(); $this->assertEquals('text/plain+special', $mimeType); } /** * @test * * @see https://github.com/thephpleague/flysystem-aws-s3-v3/issues/291 */ public function issue_291(): void { $adapter = $this->adapter(); $adapter->createDirectory('directory', new Config()); $listing = iterator_to_array($adapter->listContents('directory', true)); self::assertCount(0, $listing); } /** * @test */ public function listing_contents_recursive(): void { $adapter = $this->adapter(); $adapter->write('something/0/here.txt', 'contents', new Config()); $adapter->write('something/1/also/here.txt', 'contents', new Config()); $contents = iterator_to_array($adapter->listContents('', true)); $this->assertCount(2, $contents); $this->assertContainsOnlyInstancesOf(FileAttributes::class, $contents); /** @var FileAttributes $file */ $file = $contents[0]; $this->assertEquals('something/0/here.txt', $file->path()); /** @var FileAttributes $file */ $file = $contents[1]; $this->assertEquals('something/1/also/here.txt', $file->path()); } /** * @test */ public function failing_to_delete_while_moving(): void { $adapter = $this->adapter(); $adapter->write('source.txt', 'contents to be copied', new Config()); static::$stubS3Client->failOnNextCopy(); $this->expectException(UnableToMoveFile::class); $adapter->move('source.txt', 'destination.txt', new Config()); } /** * @test * * @see https://github.com/thephpleague/flysystem-aws-s3-v3/issues/287 */ public function issue_287(): void { $adapter = $this->adapter(); $adapter->write('KmFVvKqo/QLMExy2U/620ff60c8a154.pdf', 'pdf content', new Config()); self::assertTrue($adapter->directoryExists('KmFVvKqo')); } /** * @test */ public function failing_to_write_a_file(): void { $adapter = $this->adapter(); static::$stubS3Client->throwDuringUpload(new RuntimeException('Oh no')); $this->expectException(UnableToWriteFile::class); $adapter->write('path.txt', 'contents', new Config()); } /** * @test */ public function failing_to_delete_a_file(): void { $adapter = $this->adapter(); static::$stubS3Client->throwExceptionWhenExecutingCommand('DeleteObject'); $this->expectException(UnableToDeleteFile::class); $adapter->delete('path.txt'); } /** * @test */ public function fetching_unknown_mime_type_of_a_file(): void { $this->adapter(); $result = new Result([ 'Key' => static::$adapterPrefix . '/unknown-mime-type.md5', ]); static::$stubS3Client->stageResultForCommand('HeadObject', $result); parent::fetching_unknown_mime_type_of_a_file(); } /** * @test * * @dataProvider dpFailingMetadataGetters */ public function failing_to_retrieve_metadata(Exception $exception, string $getterName): void { $adapter = $this->adapter(); $result = new Result([ 'Key' => static::$adapterPrefix . '/filename.txt', ]); static::$stubS3Client->stageResultForCommand('HeadObject', $result); $this->expectExceptionObject($exception); $adapter->{$getterName}('filename.txt'); } public static function dpFailingMetadataGetters(): iterable { yield "mimeType" => [UnableToRetrieveMetadata::mimeType('filename.txt'), 'mimeType']; yield "lastModified" => [UnableToRetrieveMetadata::lastModified('filename.txt'), 'lastModified']; yield "fileSize" => [UnableToRetrieveMetadata::fileSize('filename.txt'), 'fileSize']; } /** * @test */ public function failing_to_check_for_file_existence(): void { $adapter = $this->adapter(); static::$stubS3Client->throw500ExceptionWhenExecutingCommand('HeadObject'); $this->expectException(UnableToCheckFileExistence::class); $adapter->fileExists('something-that-does-exist.txt'); } /** * @test * * @dataProvider casesWhereHttpStreamingInfluencesSeekability */ public function streaming_reads_are_not_seekable_and_non_streaming_are(bool $streaming, bool $seekable): void { if (getenv('COMPOSER_OPTS') === '--prefer-lowest') { $this->markTestSkipped('The SDK does not support streaming in low versions.'); } $adapter = $this->useAdapter($this->createFilesystemAdapter($streaming)); $this->givenWeHaveAnExistingFile('path.txt'); $resource = $adapter->readStream('path.txt'); $metadata = stream_get_meta_data($resource); fclose($resource); $this->assertEquals($seekable, $metadata['seekable']); } public static function casesWhereHttpStreamingInfluencesSeekability(): Generator { yield "not streaming reads have seekable stream" => [false, true]; yield "streaming reads have non-seekable stream" => [true, false]; } /** * @test * * @dataProvider casesWhereHttpStreamingInfluencesSeekability */ public function configuring_http_streaming_via_options(bool $streaming): void { $adapter = $this->useAdapter($this->createFilesystemAdapter($streaming, ['@http' => ['stream' => false]])); $this->givenWeHaveAnExistingFile('path.txt'); $resource = $adapter->readStream('path.txt'); $metadata = stream_get_meta_data($resource); fclose($resource); $this->assertTrue($metadata['seekable']); } /** * @test * * @dataProvider casesWhereHttpStreamingInfluencesSeekability */ public function use_globally_configured_options(bool $streaming): void { $adapter = $this->useAdapter($this->createFilesystemAdapter($streaming, ['ContentType' => 'text/plain+special'])); $this->givenWeHaveAnExistingFile('path.txt'); $mimeType = $adapter->mimeType('path.txt')->mimeType(); $this->assertSame('text/plain+special', $mimeType); } /** * @test */ public function moving_with_updated_metadata(): void { $adapter = $this->adapter(); $adapter->write('source.txt', 'contents to be moved', new Config(['ContentType' => 'text/plain'])); $mimeTypeSource = $adapter->mimeType('source.txt')->mimeType(); $this->assertSame('text/plain', $mimeTypeSource); $adapter->move('source.txt', 'destination.txt', new Config( ['ContentType' => 'text/plain+special', 'MetadataDirective' => 'REPLACE'] )); $mimeTypeDestination = $adapter->mimeType('destination.txt')->mimeType(); $this->assertSame('text/plain+special', $mimeTypeDestination); } /** * @test */ public function moving_without_updated_metadata(): void { $adapter = $this->adapter(); $adapter->write('source.txt', 'contents to be moved', new Config(['ContentType' => 'text/plain'])); $mimeTypeSource = $adapter->mimeType('source.txt')->mimeType(); $this->assertSame('text/plain', $mimeTypeSource); $adapter->move('source.txt', 'destination.txt', new Config( ['ContentType' => 'text/plain+special'] )); $mimeTypeDestination = $adapter->mimeType('destination.txt')->mimeType(); $this->assertSame('text/plain', $mimeTypeDestination); } /** * @test */ public function copying_with_updated_metadata(): void { $adapter = $this->adapter(); $adapter->write('source.txt', 'contents to be moved', new Config(['ContentType' => 'text/plain'])); $mimeTypeSource = $adapter->mimeType('source.txt')->mimeType(); $this->assertSame('text/plain', $mimeTypeSource); $adapter->copy('source.txt', 'destination.txt', new Config( ['ContentType' => 'text/plain+special', 'MetadataDirective' => 'REPLACE'] )); $mimeTypeDestination = $adapter->mimeType('destination.txt')->mimeType(); $this->assertSame('text/plain+special', $mimeTypeDestination); } /** * @test */ public function setting_acl_via_options(): void { $adapter = $this->adapter(); $prefixer = new PathPrefixer(static::$adapterPrefix); $prefixedPath = $prefixer->prefixPath('path.txt'); $adapter->write('path.txt', 'contents', new Config(['ACL' => 'bucket-owner-full-control'])); $arguments = ['Bucket' => getenv('FLYSYSTEM_AWS_S3_BUCKET'), 'Key' => $prefixedPath]; $command = static::$s3Client->getCommand('GetObjectAcl', $arguments); $response = static::$s3Client->execute($command)->toArray(); $permission = $response['Grants'][0]['Permission']; self::assertEquals('FULL_CONTROL', $permission); } /** * @test */ public function moving_a_file_with_visibility(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->move('source.txt', 'destination.txt', new Config([Config::OPTION_VISIBILITY => Visibility::PRIVATE])); $this->assertFalse( $adapter->fileExists('source.txt'), 'After moving a file should no longer exist in the original location.' ); $this->assertTrue( $adapter->fileExists('destination.txt'), 'After moving, a file should be present at the new location.' ); $this->assertEquals(Visibility::PRIVATE, $adapter->visibility('destination.txt')->visibility()); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function specifying_a_custom_checksum_algo_is_not_supported(): void { /** @var AwsS3V3Adapter $adapter */ $adapter = $this->adapter(); $this->expectException(ChecksumAlgoIsNotSupported::class); $adapter->checksum('something', new Config(['checksum_algo' => 'md5'])); } /** * @test */ public function copying_a_file_with_visibility(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->copy('source.txt', 'destination.txt', new Config([Config::OPTION_VISIBILITY => Visibility::PRIVATE])); $this->assertTrue($adapter->fileExists('source.txt')); $this->assertTrue($adapter->fileExists('destination.txt')); $this->assertEquals(Visibility::PRIVATE, $adapter->visibility('destination.txt')->visibility()); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } protected static function createFilesystemAdapter(bool $streaming = true, array $options = []): FilesystemAdapter { static::$stubS3Client = new S3ClientStub(static::s3Client()); /** @var string $bucket */ $bucket = getenv('FLYSYSTEM_AWS_S3_BUCKET'); $prefix = static::$adapterPrefix; return new AwsS3V3Adapter(static::$stubS3Client, $bucket, $prefix, null, null, $options, $streaming); } } ================================================ FILE: src/AwsS3V3/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/AwsS3V3/PortableVisibilityConverter.php ================================================ defaultForDirectories; } } ================================================ FILE: src/AwsS3V3/README.md ================================================ ## Sub-split of Flysystem for AWS S3. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-aws-s3-v3 ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/aws-s3-v3/). ================================================ FILE: src/AwsS3V3/S3ClientStub.php ================================================ actualClient = $client; } public function throwDuringUpload(Throwable $throwable): void { $this->exceptionForUpload = $throwable; } public function upload($bucket, $key, $body, $acl = 'private', array $options = []) { if ($this->exceptionForUpload instanceof Throwable) { $throwable = $this->exceptionForUpload; $this->exceptionForUpload = null; throw $throwable; } return $this->actualClient->upload($bucket, $key, $body, $acl, $options); } public function failOnNextCopy(): void { $this->throwExceptionWhenExecutingCommand('CopyObject'); } public function throwExceptionWhenExecutingCommand(string $commandName, ?S3Exception $exception = null): void { $this->stagedExceptions[$commandName] = $exception ?? new S3Exception($commandName, new Command($commandName)); } public function throw500ExceptionWhenExecutingCommand(string $commandName): void { $response = new Response(500); $exception = new S3Exception($commandName, new Command($commandName), compact('response')); $this->throwExceptionWhenExecutingCommand($commandName, $exception); } public function stageResultForCommand(string $commandName, ResultInterface $result): void { $this->stagedResult[$commandName] = $result; } public function execute(CommandInterface $command) { return $this->executeAsync($command)->wait(); } public function getCommand($name, array $args = []) { return $this->actualClient->getCommand($name, $args); } public function getHandlerList() { return $this->actualClient->getHandlerList(); } public function getIterator($name, array $args = []) { return $this->actualClient->getIterator($name, $args); } public function __call($name, array $arguments) { return $this->actualClient->__call($name, $arguments); } public function executeAsync(CommandInterface $command) { $name = $command->getName(); if (array_key_exists($name, $this->stagedExceptions)) { $exception = $this->stagedExceptions[$name]; unset($this->stagedExceptions[$name]); throw $exception; } if (array_key_exists($name, $this->stagedResult)) { $result = $this->stagedResult[$name]; unset($this->stagedResult[$name]); return promise_for($result); } return $this->actualClient->executeAsync($command); } public function getCredentials() { return $this->actualClient->getCredentials(); } public function getRegion() { return $this->actualClient->getRegion(); } public function getEndpoint() { return $this->actualClient->getEndpoint(); } public function getApi() { return $this->actualClient->getApi(); } public function getConfig($option = null) { return $this->actualClient->getConfig($option); } public function getPaginator($name, array $args = []) { return $this->actualClient->getPaginator($name, $args); } public function waitUntil($name, array $args = []) { $this->actualClient->waitUntil($name, $args); } public function getWaiter($name, array $args = []) { return $this->actualClient->getWaiter($name, $args); } public function createPresignedRequest(CommandInterface $command, $expires, array $options = []) { return $this->actualClient->createPresignedRequest($command, $expires, $options); } public function getObjectUrl($bucket, $key) { return $this->actualClient->getObjectUrl($bucket, $key); } } ================================================ FILE: src/AwsS3V3/VisibilityConverter.php ================================================ prefixer = new PathPrefixer($prefix); $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); } public function copy(string $source, string $destination, Config $config): void { $resolvedDestination = $this->prefixer->prefixPath($destination); $resolvedSource = $this->prefixer->prefixPath($source); try { $this->client->copyBlob( $this->container, $resolvedDestination, $this->container, $resolvedSource ); } catch (Throwable $throwable) { throw UnableToCopyFile::fromLocationTo($source, $destination, $throwable); } } public function delete(string $path): void { $location = $this->prefixer->prefixPath($path); try { $this->client->deleteBlob($this->container, $location); } catch (Throwable $exception) { if ($exception instanceof ServiceException && $exception->getCode() === 404) { return; } throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception); } } public function read(string $path): string { $response = $this->readStream($path); return stream_get_contents($response); } public function readStream(string $path) { $location = $this->prefixer->prefixPath($path); try { $response = $this->client->getBlob($this->container, $location); return $response->getContentStream(); } catch (Throwable $exception) { throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); } } public function listContents(string $path, bool $deep = false): iterable { $resolved = $this->prefixer->prefixDirectoryPath($path); $options = new ListBlobsOptions(); $options->setPrefix($resolved); $options->setMaxResults($this->maxResultsForContentsListing); if ($deep === false) { $options->setDelimiter('/'); } do { $response = $this->client->listBlobs($this->container, $options); foreach ($response->getBlobPrefixes() as $blobPrefix) { yield new DirectoryAttributes($this->prefixer->stripDirectoryPrefix($blobPrefix->getName())); } foreach ($response->getBlobs() as $blob) { yield $this->normalizeBlobProperties( $this->prefixer->stripPrefix($blob->getName()), $blob->getProperties() ); } $continuationToken = $response->getContinuationToken(); $options->setContinuationToken($continuationToken); } while ($continuationToken instanceof ContinuationToken); } public function fileExists(string $path): bool { $resolved = $this->prefixer->prefixPath($path); try { return $this->fetchMetadata($resolved) !== null; } catch (Throwable $exception) { if ($exception instanceof ServiceException && $exception->getCode() === 404) { return false; } throw UnableToCheckFileExistence::forLocation($path, $exception); } } public function directoryExists(string $path): bool { $resolved = $this->prefixer->prefixDirectoryPath($path); $options = new ListBlobsOptions(); $options->setPrefix($resolved); $options->setMaxResults(1); try { $listResults = $this->client->listBlobs($this->container, $options); return count($listResults->getBlobs()) > 0; } catch (Throwable $exception) { throw UnableToCheckDirectoryExistence::forLocation($path, $exception); } } public function deleteDirectory(string $path): void { $resolved = $this->prefixer->prefixDirectoryPath($path); $options = new ListBlobsOptions(); $options->setPrefix($resolved); try { start: $listResults = $this->client->listBlobs($this->container, $options); foreach ($listResults->getBlobs() as $blob) { $this->client->deleteBlob($this->container, $blob->getName()); } $continuationToken = $listResults->getContinuationToken(); if ($continuationToken instanceof ContinuationToken) { $options->setContinuationToken($continuationToken); goto start; } } catch (Throwable $exception) { throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception); } } public function createDirectory(string $path, Config $config): void { // this is not supported by Azure } public function setVisibility(string $path, string $visibility): void { if ($this->visibilityHandling === self::ON_VISIBILITY_THROW_ERROR) { throw UnableToSetVisibility::atLocation($path, 'Azure does not support this operation.'); } } public function visibility(string $path): FileAttributes { throw UnableToRetrieveMetadata::visibility($path, 'Azure does not support visibility'); } public function mimeType(string $path): FileAttributes { try { return $this->fetchMetadata($this->prefixer->prefixPath($path)); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::mimeType($path, $exception->getMessage(), $exception); } } public function lastModified(string $path): FileAttributes { try { return $this->fetchMetadata($this->prefixer->prefixPath($path)); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::lastModified($path, $exception->getMessage(), $exception); } } public function fileSize(string $path): FileAttributes { try { return $this->fetchMetadata($this->prefixer->prefixPath($path)); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::fileSize($path, $exception->getMessage(), $exception); } } public function move(string $source, string $destination, Config $config): void { try { $this->copy($source, $destination, $config); $this->delete($source); } catch (Throwable $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } } public function write(string $path, string $contents, Config $config): void { $this->upload($path, $contents, $config); } public function writeStream(string $path, $contents, Config $config): void { $this->upload($path, $contents, $config); } /** * @param string|resource $contents */ private function upload(string $destination, $contents, Config $config): void { $resolved = $this->prefixer->prefixPath($destination); try { $options = $this->getOptionsFromConfig($config); if (empty($options->getContentType())) { $options->setContentType($this->mimeTypeDetector->detectMimeType($resolved, $contents)); } $this->client->createBlockBlob( $this->container, $resolved, $contents, $options ); } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($destination, $exception->getMessage(), $exception); } } private function fetchMetadata(string $path): FileAttributes { return $this->normalizeBlobProperties( $path, $this->client->getBlobProperties($this->container, $path)->getProperties() ); } private function getOptionsFromConfig(Config $config): CreateBlockBlobOptions { $options = new CreateBlockBlobOptions(); foreach (self::META_OPTIONS as $option) { $setting = $config->get($option, '___NOT__SET___'); if ($setting === '___NOT__SET___') { continue; } call_user_func([$options, "set$option"], $setting); } $mimeType = $config->get('mimetype'); if ($mimeType !== null) { $options->setContentType($mimeType); } return $options; } private function normalizeBlobProperties(string $path, BlobProperties $properties): FileAttributes { return new FileAttributes( $path, $properties->getContentLength(), null, $properties->getLastModified()->getTimestamp(), $properties->getContentType(), ['md5_checksum' => $properties->getContentMD5()] ); } public function publicUrl(string $path, Config $config): string { $location = $this->prefixer->prefixPath($path); return $this->client->getBlobUrl($this->container, $location); } public function checksum(string $path, Config $config): string { $algo = $config->get('checksum_algo', 'md5'); if ($algo !== 'md5') { throw new ChecksumAlgoIsNotSupported(); } try { $metadata = $this->fetchMetadata($this->prefixer->prefixPath($path)); $checksum = $metadata->extraMetadata()['md5_checksum'] ?? '__not_specified'; } catch (Throwable $exception) { throw new UnableToProvideChecksum($exception->getMessage(), $path, $exception); } if ($checksum === '__not_specified') { throw new UnableToProvideChecksum('No checksum provided in metadata', $path); } return bin2hex(base64_decode($checksum)); } public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string { if ( ! $this->serviceSettings instanceof StorageServiceSettings) { throw UnableToGenerateTemporaryUrl::noGeneratorConfigured( $path, 'The $serviceSettings constructor parameter must be set to generate temporary URLs.', ); } try { $sas = new BlobSharedAccessSignatureHelper($this->serviceSettings->getName(), $this->serviceSettings->getKey()); $baseUrl = $this->publicUrl($path, $config); $resourceName = $this->container . '/' . ltrim($this->prefixer->prefixPath($path), '/'); $token = $sas->generateBlobServiceSharedAccessSignatureToken( Resources::RESOURCE_TYPE_BLOB, $resourceName, 'r', // read DateTime::createFromInterface($expiresAt), $config->get('signed_start', ''), $config->get('signed_ip', ''), $config->get('signed_protocol', 'https'), $config->get('signed_identifier', ''), $config->get('cache_control', ''), $config->get('content_disposition', $config->get('content_deposition', '')), $config->get('content_encoding', ''), $config->get('content_language', ''), $config->get('content_type', ''), ); return "$baseUrl?$token"; } catch (Throwable $exception) { throw UnableToGenerateTemporaryUrl::dueToError($path, $exception); } } } ================================================ FILE: src/AzureBlobStorage/AzureBlobStorageAdapterTest.php ================================================ runScenario( function () { $this->givenWeHaveAnExistingFile('path.txt', 'contents'); $adapter = $this->adapter(); $adapter->write('path.txt', 'new contents', new Config()); $contents = $adapter->read('path.txt'); $this->assertEquals('new contents', $contents); } ); } /** * @test */ public function setting_visibility(): void { self::markTestSkipped('Azure does not support visibility'); } /** * @test */ public function failing_to_set_visibility(): void { self::markTestSkipped('Azure does not support visibility'); } /** * @test */ public function failing_to_check_visibility(): void { self::markTestSkipped('Azure does not support visibility'); } public function fetching_unknown_mime_type_of_a_file(): void { $this->markTestSkipped('This adapter always returns a mime-type'); } public function listing_contents_recursive(): void { $this->markTestSkipped('This adapter does not support creating directories'); } /** * @test */ public function copying_a_file(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->copy('source.txt', 'destination.txt', new Config()); $this->assertTrue($adapter->fileExists('source.txt')); $this->assertTrue($adapter->fileExists('destination.txt')); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function moving_a_file(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->move('source.txt', 'destination.txt', new Config()); $this->assertFalse( $adapter->fileExists('source.txt'), 'After moving a file should no longer exist in the original location.' ); $this->assertTrue( $adapter->fileExists('destination.txt'), 'After moving, a file should be present at the new location.' ); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function copying_a_file_again(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config() ); $adapter->copy('source.txt', 'destination.txt', new Config()); $this->assertTrue($adapter->fileExists('source.txt')); $this->assertTrue($adapter->fileExists('destination.txt')); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function setting_visibility_can_be_ignored_not_supported(): void { $this->givenWeHaveAnExistingFile('some-file.md'); $this->expectNotToPerformAssertions(); $client = BlobRestProxy::createBlobService(getenv('FLYSYSTEM_AZURE_DSN')); $adapter = new AzureBlobStorageAdapter($client, self::CONTAINER_NAME, 'ci', null, 50000, AzureBlobStorageAdapter::ON_VISIBILITY_IGNORE); $adapter->setVisibility('some-file.md', 'public'); } /** * @test */ public function setting_visibility_causes_errors(): void { $this->givenWeHaveAnExistingFile('some-file.md'); $adapter = $this->adapter(); $this->expectException(UnableToSetVisibility::class); $adapter->setVisibility('some-file.md', 'public'); } /** * @test */ public function checking_if_a_directory_exists_after_creating_it(): void { $this->markTestSkipped('This adapter does not support creating directories'); } /** * @test */ public function setting_visibility_on_a_file_that_does_not_exist(): void { $this->markTestSkipped('This adapter does not support visibility'); } /** * @test */ public function creating_a_directory(): void { $this->markTestSkipped('This adapter does not support creating directories'); } } ================================================ FILE: src/AzureBlobStorage/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/AzureBlobStorage/README.md ================================================ ## Sub-split for Flysystem's Azure Blob Storage Adapter > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ================================================ FILE: src/AzureBlobStorage/composer.json ================================================ { "name": "league/flysystem-azure-blob-storage", "autoload": { "psr-4": { "League\\Flysystem\\AzureBlobStorage\\": "" } }, "require": { "php": "^8.0.2", "league/flysystem": "^3.10.0", "microsoft/azure-storage-blob": "^1.1" }, "license": "MIT", "authors": [ { "name": "Frank de Jonge", "email": "info@frankdejonge.nl" } ], "abandoned": "azure-oss/storage-blob-flysystem" } ================================================ FILE: src/CalculateChecksumFromStream.php ================================================ readStream($path); $algo = (string) $config->get('checksum_algo', 'md5'); $context = hash_init($algo); hash_update_stream($context, $stream); return hash_final($context); } catch (FilesystemException $exception) { throw new UnableToProvideChecksum($exception->getMessage(), $path, $exception); } } /** * @return resource */ abstract public function readStream(string $path); } ================================================ FILE: src/ChecksumAlgoIsNotSupported.php ================================================ options[$property] ?? $default; } public function extend(array $options): Config { return new Config(array_merge($this->options, $options)); } public function withDefaults(array $defaults): Config { return new Config($this->options + $defaults); } public function toArray(): array { return $this->options; } public function withSetting(string $property, mixed $setting): Config { return $this->extend([$property => $setting]); } public function withoutSettings(string ...$settings): Config { return new Config(array_diff_key($this->options, array_flip($settings))); } } ================================================ FILE: src/ConfigTest.php ================================================ 'value']); $this->assertEquals('value', $config->get('option')); } /** * @test */ public function a_config_object_returns_a_default_value(): void { $config = new Config(); $this->assertNull($config->get('option')); $this->assertEquals('default', $config->get('option', 'default')); } /** * @test */ public function extending_a_config_with_options(): void { $config = new Config(['option' => 'value', 'first' => 1]); $extended = $config->extend(['option' => 'overwritten', 'second' => 2]); $this->assertEquals('overwritten', $extended->get('option')); $this->assertEquals(1, $extended->get('first')); $this->assertEquals(2, $extended->get('second')); } /** * @test */ public function extending_with_defaults(): void { $config = new Config(['option' => 'set']); $withDefaults = $config->withDefaults(['option' => 'default', 'other' => 'default']); $this->assertEquals('set', $withDefaults->get('option')); $this->assertEquals('default', $withDefaults->get('other')); } /** * @test */ public function extending_without_settings(): void { // arrange $config = new Config(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]); // act $withoutSetting = $config->withoutSettings('b', 'd'); // assert $this->assertEquals(['a' => 1, 'c' => 3], $withoutSetting->toArray()); } } ================================================ FILE: src/CorruptedPathDetected.php ================================================ adapter->fileExists($path); } public function directoryExists(string $path): bool { return $this->adapter->directoryExists($path); } public function write(string $path, string $contents, Config $config): void { $this->adapter->write($path, $contents, $config); } public function writeStream(string $path, $contents, Config $config): void { $this->adapter->writeStream($path, $contents, $config); } public function read(string $path): string { return $this->adapter->read($path); } public function readStream(string $path) { return $this->adapter->readStream($path); } public function delete(string $path): void { $this->adapter->delete($path); } public function deleteDirectory(string $path): void { $this->adapter->deleteDirectory($path); } public function createDirectory(string $path, Config $config): void { $this->adapter->createDirectory($path, $config); } public function setVisibility(string $path, string $visibility): void { $this->adapter->setVisibility($path, $visibility); } public function visibility(string $path): FileAttributes { return $this->adapter->visibility($path); } public function mimeType(string $path): FileAttributes { return $this->adapter->mimeType($path); } public function lastModified(string $path): FileAttributes { return $this->adapter->lastModified($path); } public function fileSize(string $path): FileAttributes { return $this->adapter->fileSize($path); } public function listContents(string $path, bool $deep): iterable { return $this->adapter->listContents($path, $deep); } public function move(string $source, string $destination, Config $config): void { $this->adapter->move($source, $destination, $config); } public function copy(string $source, string $destination, Config $config): void { $this->adapter->copy($source, $destination, $config); } } ================================================ FILE: src/DirectoryAttributes.php ================================================ path = trim($this->path, '/'); } public function path(): string { return $this->path; } public function type(): string { return $this->type; } public function visibility(): ?string { return $this->visibility; } public function lastModified(): ?int { return $this->lastModified; } public function extraMetadata(): array { return $this->extraMetadata; } public function isFile(): bool { return false; } public function isDir(): bool { return true; } public function withPath(string $path): self { $clone = clone $this; $clone->path = $path; return $clone; } public static function fromArray(array $attributes): self { return new DirectoryAttributes( $attributes[StorageAttributes::ATTRIBUTE_PATH], $attributes[StorageAttributes::ATTRIBUTE_VISIBILITY] ?? null, $attributes[StorageAttributes::ATTRIBUTE_LAST_MODIFIED] ?? null, $attributes[StorageAttributes::ATTRIBUTE_EXTRA_METADATA] ?? [] ); } /** * @inheritDoc */ public function jsonSerialize(): array { return [ StorageAttributes::ATTRIBUTE_TYPE => $this->type, StorageAttributes::ATTRIBUTE_PATH => $this->path, StorageAttributes::ATTRIBUTE_VISIBILITY => $this->visibility, StorageAttributes::ATTRIBUTE_LAST_MODIFIED => $this->lastModified, StorageAttributes::ATTRIBUTE_EXTRA_METADATA => $this->extraMetadata, ]; } } ================================================ FILE: src/DirectoryAttributesTest.php ================================================ assertTrue($attrs->isDir()); $this->assertFalse($attrs->isFile()); $this->assertEquals(StorageAttributes::TYPE_DIRECTORY, $attrs->type()); $this->assertEquals('some/path', $attrs->path()); $this->assertNull($attrs->visibility()); } /** * @test */ public function exposing_visibility(): void { $attrs = new DirectoryAttributes('some/path', Visibility::PRIVATE); $this->assertEquals(Visibility::PRIVATE, $attrs->visibility()); } /** * @test */ public function exposing_last_modified(): void { $attrs = new DirectoryAttributes('some/path', null, $timestamp = time()); $this->assertEquals($timestamp, $attrs->lastModified()); } /** * @test */ public function exposing_extra_meta_data(): void { $attrs = new DirectoryAttributes('some/path', null, null, ['key' => 'value']); $this->assertEquals(['key' => 'value'], $attrs->extraMetadata()); } /** * @test */ public function serialization_capabilities(): void { $attrs = new DirectoryAttributes('some/path'); $payload = $attrs->jsonSerialize(); $attrsFromPayload = DirectoryAttributes::fromArray($payload); $this->assertEquals($attrs, $attrsFromPayload); } } ================================================ FILE: src/DirectoryListing.php ================================================ $listing */ public function __construct(private iterable $listing) { } /** * @param callable(T): bool $filter * * @return DirectoryListing */ public function filter(callable $filter): DirectoryListing { $generator = (static function (iterable $listing) use ($filter): Generator { foreach ($listing as $item) { if ($filter($item)) { yield $item; } } })($this->listing); return new DirectoryListing($generator); } /** * @template R * * @param callable(T): R $mapper * * @return DirectoryListing */ public function map(callable $mapper): DirectoryListing { $generator = (static function (iterable $listing) use ($mapper): Generator { foreach ($listing as $item) { yield $mapper($item); } })($this->listing); return new DirectoryListing($generator); } /** * @return DirectoryListing */ public function sortByPath(): DirectoryListing { $listing = $this->toArray(); usort($listing, function (StorageAttributes $a, StorageAttributes $b) { return $a->path() <=> $b->path(); }); return new DirectoryListing($listing); } /** * @return Traversable */ public function getIterator(): Traversable { return $this->listing instanceof Traversable ? $this->listing : new ArrayIterator($this->listing); } /** * @return T[] */ public function toArray(): array { return $this->listing instanceof Traversable ? iterator_to_array($this->listing, false) : (array) $this->listing; } } ================================================ FILE: src/DirectoryListingTest.php ================================================ generateIntegers(1, 10); $listing = new DirectoryListing($numbers); $mappedListing = $listing->map(function (int $i) { return $i * 2; }); $mappedNumbers = $mappedListing->toArray(); $expectedNumbers = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]; $this->assertEquals($expectedNumbers, $mappedNumbers); } /** * @test */ public function mapping_a_listing_twice(): void { $numbers = $this->generateIntegers(1, 10); $listing = new DirectoryListing($numbers); $mappedListing = $listing->map(function (int $i) { return $i * 2; }); $mappedListing = $mappedListing->map(function (int $i) { return $i / 2; }); $mappedNumbers = $mappedListing->toArray(); $expectedNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; $this->assertEquals($expectedNumbers, $mappedNumbers); } /** * @test */ public function filter_a_listing(): void { $numbers = $this->generateIntegers(1, 20); $listing = new DirectoryListing($numbers); $fileredListing = $listing->filter(function (int $i) { return $i % 2 === 0; }); $mappedNumbers = $fileredListing->toArray(); $expectedNumbers = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]; $this->assertEquals($expectedNumbers, $mappedNumbers); } /** * @test */ public function filter_a_listing_twice(): void { $numbers = $this->generateIntegers(1, 20); $listing = new DirectoryListing($numbers); $filteredListing = $listing->filter(function (int $i) { return $i % 2 === 0; }); $filteredListing = $filteredListing->filter(function (int $i) { return $i > 10; }); $mappedNumbers = $filteredListing->toArray(); $expectedNumbers = [12, 14, 16, 18, 20]; $this->assertEquals($expectedNumbers, $mappedNumbers); } /** * @test */ public function sorting_a_directory_listing(): void { $expected = ['a/a/a.txt', 'b/c/a.txt', 'c/b/a.txt', 'c/c/a.txt']; $listing = new DirectoryListing([ new FileAttributes('b/c/a.txt'), new FileAttributes('c/c/a.txt'), new FileAttributes('c/b/a.txt'), new FileAttributes('a/a/a.txt'), ]); $actual = $listing->sortByPath() ->map(function ($i) { return $i->path(); }) ->toArray(); self::assertEquals($expected, $actual); } /** * @test * * @description this ensures that the output of a sorted listing is iterable * * @see https://github.com/thephpleague/flysystem/issues/1342 */ public function iterating_over_storted_output(): void { $listing = new DirectoryListing([ new FileAttributes('b/c/a.txt'), new FileAttributes('c/c/a.txt'), new FileAttributes('c/b/a.txt'), new FileAttributes('a/a/a.txt'), ]); self::expectNotToPerformAssertions(); iterator_to_array($listing->sortByPath()); } /** * @return Generator */ private function generateIntegers(int $min, int $max): Generator { for ($i = $min; $i <= $max; $i++) { yield $i; } } } ================================================ FILE: src/ExceptionInformationTest.php ================================================ assertEquals('from', $exception->source()); $this->assertEquals('to', $exception->destination()); $this->assertEquals(FilesystemOperationFailed::OPERATION_COPY, $exception->operation()); } /** * @test */ public function create_directory_exception_information(): void { $exception = UnableToCreateDirectory::atLocation('from', 'some message'); $this->assertEquals('from', $exception->location()); $this->assertStringContainsString('some message', $exception->getMessage()); $this->assertEquals(FilesystemOperationFailed::OPERATION_CREATE_DIRECTORY, $exception->operation()); } /** * @test */ public function delete_directory_exception_information(): void { $exception = UnableToDeleteDirectory::atLocation('from', 'some message'); $this->assertEquals('some message', $exception->reason()); $this->assertEquals('from', $exception->location()); $this->assertStringContainsString('some message', $exception->getMessage()); $this->assertEquals(FilesystemOperationFailed::OPERATION_DELETE_DIRECTORY, $exception->operation()); } /** * @test */ public function delete_file_exception_information(): void { $exception = UnableToDeleteFile::atLocation('from', 'some message'); $this->assertEquals('from', $exception->location()); $this->assertEquals('some message', $exception->reason()); $this->assertStringContainsString('some message', $exception->getMessage()); $this->assertEquals(FilesystemOperationFailed::OPERATION_DELETE, $exception->operation()); } /** * @test */ public function unable_to_check_for_file_existence(): void { $exception = UnableToCheckFileExistence::forLocation('location'); $this->assertEquals(FilesystemOperationFailed::OPERATION_FILE_EXISTS, $exception->operation()); } /** * @test */ public function unable_to_check_for_existence(): void { $exception = UnableToCheckExistence::forLocation('location'); $this->assertEquals(FilesystemOperationFailed::OPERATION_EXISTENCE_CHECK, $exception->operation()); } /** * @test */ public function unable_to_check_for_directory_existence(): void { $exception = UnableToCheckDirectoryExistence::forLocation('location'); $this->assertEquals(FilesystemOperationFailed::OPERATION_DIRECTORY_EXISTS, $exception->operation()); } /** * @test */ public function move_file_exception_information(): void { $exception = UnableToMoveFile::fromLocationTo('from', 'to'); $this->assertEquals('from', $exception->source()); $this->assertEquals('to', $exception->destination()); $this->assertEquals(FilesystemOperationFailed::OPERATION_MOVE, $exception->operation()); } /** * @test */ public function read_file_exception_information(): void { $exception = UnableToReadFile::fromLocation('from', 'some message'); $this->assertEquals('from', $exception->location()); $this->assertEquals('some message', $exception->reason()); $this->assertStringContainsString('some message', $exception->getMessage()); $this->assertEquals(FilesystemOperationFailed::OPERATION_READ, $exception->operation()); } /** * @test */ public function retrieve_visibility_exception_information(): void { $exception = UnableToRetrieveMetadata::visibility('from', 'some message'); $this->assertEquals('from', $exception->location()); $this->assertEquals(FileAttributes::ATTRIBUTE_VISIBILITY, $exception->metadataType()); $this->assertStringContainsString('some message', $exception->getMessage()); $this->assertEquals(FilesystemOperationFailed::OPERATION_RETRIEVE_METADATA, $exception->operation()); } /** * @test */ public function set_visibility_exception_information(): void { $exception = UnableToSetVisibility::atLocation('from', 'some message'); $this->assertEquals('from', $exception->location()); $this->assertEquals('some message', $exception->reason()); $this->assertStringContainsString('some message', $exception->getMessage()); $this->assertEquals(FilesystemOperationFailed::OPERATION_SET_VISIBILITY, $exception->operation()); } /** * @test */ public function write_file_exception_information(): void { $exception = UnableToWriteFile::atLocation('from', 'some message'); $this->assertEquals('from', $exception->location()); $this->assertEquals('some message', $exception->reason()); $this->assertStringContainsString('some message', $exception->getMessage()); $this->assertEquals(FilesystemOperationFailed::OPERATION_WRITE, $exception->operation()); } /** * @test */ public function unreadable_file_exception_information(): void { $exception = UnreadableFileEncountered::atLocation('the-location'); $this->assertEquals('the-location', $exception->location()); $this->assertStringContainsString('the-location', $exception->getMessage()); } /** * @test */ public function symbolic_link_exception_information(): void { $exception = SymbolicLinkEncountered::atLocation('the-location'); $this->assertEquals('the-location', $exception->location()); $this->assertStringContainsString('the-location', $exception->getMessage()); } /** * @test */ public function path_traversal_exception_information(): void { $exception = PathTraversalDetected::forPath('../path.txt'); $this->assertEquals('../path.txt', $exception->path()); $this->assertStringContainsString('../path.txt', $exception->getMessage()); } } ================================================ FILE: src/FileAttributes.php ================================================ path = ltrim($this->path, '/'); } public function type(): string { return $this->type; } public function path(): string { return $this->path; } public function fileSize(): ?int { return $this->fileSize; } public function visibility(): ?string { return $this->visibility; } public function lastModified(): ?int { return $this->lastModified; } public function mimeType(): ?string { return $this->mimeType; } public function extraMetadata(): array { return $this->extraMetadata; } public function isFile(): bool { return true; } public function isDir(): bool { return false; } public function withPath(string $path): self { $clone = clone $this; $clone->path = $path; return $clone; } public static function fromArray(array $attributes): self { return new FileAttributes( $attributes[StorageAttributes::ATTRIBUTE_PATH], $attributes[StorageAttributes::ATTRIBUTE_FILE_SIZE] ?? null, $attributes[StorageAttributes::ATTRIBUTE_VISIBILITY] ?? null, $attributes[StorageAttributes::ATTRIBUTE_LAST_MODIFIED] ?? null, $attributes[StorageAttributes::ATTRIBUTE_MIME_TYPE] ?? null, $attributes[StorageAttributes::ATTRIBUTE_EXTRA_METADATA] ?? [] ); } public function jsonSerialize(): array { return [ StorageAttributes::ATTRIBUTE_TYPE => self::TYPE_FILE, StorageAttributes::ATTRIBUTE_PATH => $this->path, StorageAttributes::ATTRIBUTE_FILE_SIZE => $this->fileSize, StorageAttributes::ATTRIBUTE_VISIBILITY => $this->visibility, StorageAttributes::ATTRIBUTE_LAST_MODIFIED => $this->lastModified, StorageAttributes::ATTRIBUTE_MIME_TYPE => $this->mimeType, StorageAttributes::ATTRIBUTE_EXTRA_METADATA => $this->extraMetadata, ]; } } ================================================ FILE: src/FileAttributesTest.php ================================================ assertFalse($attrs->isDir()); $this->assertTrue($attrs->isFile()); $this->assertEquals('path.txt', $attrs->path()); $this->assertEquals(StorageAttributes::TYPE_FILE, $attrs->type()); $this->assertNull($attrs->visibility()); $this->assertNull($attrs->fileSize()); $this->assertNull($attrs->mimeType()); $this->assertNull($attrs->lastModified()); } /** * @test */ public function exposing_all_values(): void { $attrs = new FileAttributes('path.txt', 1234, Visibility::PRIVATE, $now = time(), 'plain/text', ['key' => 'value']); $this->assertEquals('path.txt', $attrs->path()); $this->assertEquals(StorageAttributes::TYPE_FILE, $attrs->type()); $this->assertEquals(Visibility::PRIVATE, $attrs->visibility()); $this->assertEquals(1234, $attrs->fileSize()); $this->assertEquals($now, $attrs->lastModified()); $this->assertEquals('plain/text', $attrs->mimeType()); $this->assertEquals(['key' => 'value'], $attrs->extraMetadata()); } /** * @test */ public function implements_array_access(): void { $attrs = new FileAttributes('path.txt', 1234, Visibility::PRIVATE, $now = time(), 'plain/text', ['key' => 'value']); $this->assertEquals('path.txt', $attrs['path']); $this->assertTrue(isset($attrs['path'])); $this->assertEquals(StorageAttributes::TYPE_FILE, $attrs['type']); $this->assertEquals(Visibility::PRIVATE, $attrs['visibility']); $this->assertEquals(1234, $attrs['file_size']); $this->assertEquals($now, $attrs['last_modified']); $this->assertEquals('plain/text', $attrs['mimeType']); $this->assertEquals(['key' => 'value'], $attrs['extra_metadata']); } /** * @test */ public function properties_can_not_be_set(): void { $this->expectException(RuntimeException::class); $attrs = new FileAttributes('path.txt'); $attrs['visibility'] = Visibility::PUBLIC; } /** * @test */ public function properties_can_not_be_unset(): void { $this->expectException(RuntimeException::class); $attrs = new FileAttributes('path.txt'); unset($attrs['visibility']); } /** * @dataProvider data_provider_for_json_transformation * * @test */ public function json_transformations(FileAttributes $attributes): void { $payload = $attributes->jsonSerialize(); $newAttributes = FileAttributes::fromArray($payload); $this->assertEquals($attributes, $newAttributes); } public static function data_provider_for_json_transformation(): Generator { yield [new FileAttributes('path.txt', 1234, Visibility::PRIVATE, $now = time(), 'plain/text', ['key' => 'value'])]; yield [new FileAttributes('another.txt')]; } } ================================================ FILE: src/Filesystem.php ================================================ config = new Config($config); $this->pathNormalizer = $pathNormalizer ?? new WhitespacePathNormalizer(); } public function fileExists(string $location): bool { return $this->adapter->fileExists($this->pathNormalizer->normalizePath($location)); } public function directoryExists(string $location): bool { return $this->adapter->directoryExists($this->pathNormalizer->normalizePath($location)); } public function has(string $location): bool { $path = $this->pathNormalizer->normalizePath($location); return $this->adapter->fileExists($path) || $this->adapter->directoryExists($path); } public function write(string $location, string $contents, array $config = []): void { $this->adapter->write( $this->pathNormalizer->normalizePath($location), $contents, $this->config->extend($config) ); } public function writeStream(string $location, $contents, array $config = []): void { /* @var resource $contents */ $this->assertIsResource($contents); $this->rewindStream($contents); $this->adapter->writeStream( $this->pathNormalizer->normalizePath($location), $contents, $this->config->extend($config) ); } public function read(string $location): string { return $this->adapter->read($this->pathNormalizer->normalizePath($location)); } public function readStream(string $location) { return $this->adapter->readStream($this->pathNormalizer->normalizePath($location)); } public function delete(string $location): void { $this->adapter->delete($this->pathNormalizer->normalizePath($location)); } public function deleteDirectory(string $location): void { $this->adapter->deleteDirectory($this->pathNormalizer->normalizePath($location)); } public function createDirectory(string $location, array $config = []): void { $this->adapter->createDirectory( $this->pathNormalizer->normalizePath($location), $this->config->extend($config) ); } public function listContents(string $location, bool $deep = self::LIST_SHALLOW): DirectoryListing { $path = $this->pathNormalizer->normalizePath($location); $listing = $this->adapter->listContents($path, $deep); return new DirectoryListing($this->pipeListing($location, $deep, $listing)); } private function pipeListing(string $location, bool $deep, iterable $listing): Generator { try { foreach ($listing as $item) { yield $item; } } catch (Throwable $exception) { throw UnableToListContents::atLocation($location, $deep, $exception); } } public function move(string $source, string $destination, array $config = []): void { $config = $this->resolveConfigForMoveAndCopy($config); $from = $this->pathNormalizer->normalizePath($source); $to = $this->pathNormalizer->normalizePath($destination); if ($from === $to) { $resolutionStrategy = $config->get(Config::OPTION_MOVE_IDENTICAL_PATH, ResolveIdenticalPathConflict::TRY); if ($resolutionStrategy === ResolveIdenticalPathConflict::FAIL) { throw UnableToMoveFile::sourceAndDestinationAreTheSame($source, $destination); } elseif ($resolutionStrategy === ResolveIdenticalPathConflict::IGNORE) { return; } } $this->adapter->move($from, $to, $config); } public function copy(string $source, string $destination, array $config = []): void { $config = $this->resolveConfigForMoveAndCopy($config); $from = $this->pathNormalizer->normalizePath($source); $to = $this->pathNormalizer->normalizePath($destination); if ($from === $to) { $resolutionStrategy = $config->get(Config::OPTION_COPY_IDENTICAL_PATH, ResolveIdenticalPathConflict::TRY); if ($resolutionStrategy === ResolveIdenticalPathConflict::FAIL) { throw UnableToCopyFile::sourceAndDestinationAreTheSame($source, $destination); } elseif ($resolutionStrategy === ResolveIdenticalPathConflict::IGNORE) { return; } } $this->adapter->copy($from, $to, $config); } public function lastModified(string $path): int { return $this->adapter->lastModified($this->pathNormalizer->normalizePath($path))->lastModified(); } public function fileSize(string $path): int { return $this->adapter->fileSize($this->pathNormalizer->normalizePath($path))->fileSize(); } public function mimeType(string $path): string { return $this->adapter->mimeType($this->pathNormalizer->normalizePath($path))->mimeType(); } public function setVisibility(string $path, string $visibility): void { $this->adapter->setVisibility($this->pathNormalizer->normalizePath($path), $visibility); } public function visibility(string $path): string { return $this->adapter->visibility($this->pathNormalizer->normalizePath($path))->visibility(); } public function publicUrl(string $path, array $config = []): string { $this->publicUrlGenerator ??= $this->resolvePublicUrlGenerator() ?? throw UnableToGeneratePublicUrl::noGeneratorConfigured($path); $config = $this->config->extend($config); return $this->publicUrlGenerator->publicUrl( $this->pathNormalizer->normalizePath($path), $config, ); } public function temporaryUrl(string $path, DateTimeInterface $expiresAt, array $config = []): string { $generator = $this->temporaryUrlGenerator ?? $this->adapter; if ($generator instanceof TemporaryUrlGenerator) { return $generator->temporaryUrl( $this->pathNormalizer->normalizePath($path), $expiresAt, $this->config->extend($config) ); } throw UnableToGenerateTemporaryUrl::noGeneratorConfigured($path); } public function checksum(string $path, array $config = []): string { $config = $this->config->extend($config); if ( ! $this->adapter instanceof ChecksumProvider) { return $this->calculateChecksumFromStream($path, $config); } try { return $this->adapter->checksum( $this->pathNormalizer->normalizePath($path), $config, ); } catch (ChecksumAlgoIsNotSupported) { return $this->calculateChecksumFromStream( $this->pathNormalizer->normalizePath($path), $config, ); } } private function resolvePublicUrlGenerator(): ?PublicUrlGenerator { if ($publicUrl = $this->config->get('public_url')) { return match (true) { is_array($publicUrl) => new ShardedPrefixPublicUrlGenerator($publicUrl), default => new PrefixPublicUrlGenerator($publicUrl), }; } if ($this->adapter instanceof PublicUrlGenerator) { return $this->adapter; } return null; } /** * @param mixed $contents */ private function assertIsResource($contents): void { if (is_resource($contents) === false) { throw new InvalidStreamProvided( "Invalid stream provided, expected stream resource, received " . gettype($contents) ); } elseif ($type = get_resource_type($contents) !== 'stream') { throw new InvalidStreamProvided( "Invalid stream provided, expected stream resource, received resource of type " . $type ); } } /** * @param resource $resource */ private function rewindStream($resource): void { if (ftell($resource) !== 0 && stream_get_meta_data($resource)['seekable']) { rewind($resource); } } private function resolveConfigForMoveAndCopy(array $config): Config { $retainVisibility = $this->config->get(Config::OPTION_RETAIN_VISIBILITY, $config[Config::OPTION_RETAIN_VISIBILITY] ?? true); $fullConfig = $this->config->extend($config); /* * By default, we retain visibility. When we do not retain visibility, the visibility setting * from the default configuration is ignored. Only when it is set explicitly, we propagate the * setting. */ if ($retainVisibility && ! array_key_exists(Config::OPTION_VISIBILITY, $config)) { $fullConfig = $fullConfig->withoutSettings(Config::OPTION_VISIBILITY)->extend($config); } return $fullConfig; } } ================================================ FILE: src/FilesystemAdapter.php ================================================ * * @throws FilesystemException */ public function listContents(string $path, bool $deep): iterable; /** * @throws UnableToMoveFile * @throws FilesystemException */ public function move(string $source, string $destination, Config $config): void; /** * @throws UnableToCopyFile * @throws FilesystemException */ public function copy(string $source, string $destination, Config $config): void; } ================================================ FILE: src/FilesystemException.php ================================================ * * @throws FilesystemException * @throws UnableToListContents */ public function listContents(string $location, bool $deep = self::LIST_SHALLOW): DirectoryListing; /** * @throws UnableToRetrieveMetadata * @throws FilesystemException */ public function lastModified(string $path): int; /** * @throws UnableToRetrieveMetadata * @throws FilesystemException */ public function fileSize(string $path): int; /** * @throws UnableToRetrieveMetadata * @throws FilesystemException */ public function mimeType(string $path): string; /** * @throws UnableToRetrieveMetadata * @throws FilesystemException */ public function visibility(string $path): string; } ================================================ FILE: src/FilesystemTest.php ================================================ filesystem = $filesystem; } /** * @after */ public function removeFiles(): void { delete_directory(static::ROOT); } /** * @test */ public function writing_and_reading_files(): void { $this->filesystem->write('path.txt', 'contents'); $contents = $this->filesystem->read('path.txt'); $this->assertEquals('contents', $contents); } /** * @test * * @dataProvider invalidStreamInput * * @param mixed $input */ public function trying_to_write_with_an_invalid_stream_arguments($input): void { $this->expectException(InvalidStreamProvided::class); $this->filesystem->writeStream('path.txt', $input); } public static function invalidStreamInput(): Generator { $handle = tmpfile(); fclose($handle); yield "resource that is not open" => [$handle]; yield "something that is not a resource" => [false]; } /** * @test */ public function writing_and_reading_a_stream(): void { $writeStream = stream_with_contents('contents'); $this->filesystem->writeStream('path.txt', $writeStream); $readStream = $this->filesystem->readStream('path.txt'); fclose($writeStream); $this->assertIsResource($readStream); $this->assertEquals('contents', stream_get_contents($readStream)); fclose($readStream); } /** * @test */ public function writing_using_a_stream_wrapper(): void { $contents = 'contents of the file'; $stream = Utils::streamFor($contents); $resource = StreamWrapper::getResource($stream); $this->filesystem->writeStream('from-stream-wrapper.txt', $resource); fclose($resource); $this->assertEquals($contents, $this->filesystem->read('from-stream-wrapper.txt')); } /** * @test */ public function checking_if_files_exist(): void { $this->filesystem->write('path.txt', 'contents'); $pathDotTxtExists = $this->filesystem->fileExists('path.txt'); $otherFileExists = $this->filesystem->fileExists('other.txt'); $this->assertTrue($pathDotTxtExists); $this->assertFalse($otherFileExists); } /** * @test */ public function checking_if_directories_exist(): void { $this->filesystem->createDirectory('existing-directory'); $existingDirectory = $this->filesystem->directoryExists('existing-directory'); $notExistingDirectory = $this->filesystem->directoryExists('not-existing-directory'); $this->assertTrue($existingDirectory); $this->assertFalse($notExistingDirectory); } /** * @test */ public function deleting_a_file(): void { $this->filesystem->write('path.txt', 'content'); $this->filesystem->delete('path.txt'); $this->assertFalse($this->filesystem->fileExists('path.txt')); } /** * @test */ public function creating_a_directory(): void { $this->filesystem->createDirectory('here'); $directoryAttrs = $this->filesystem->listContents('')->toArray()[0]; $this->assertInstanceOf(DirectoryAttributes::class, $directoryAttrs); $this->assertEquals('here', $directoryAttrs->path()); } /** * @test */ public function deleting_a_directory(): void { $this->filesystem->write('dirname/a.txt', 'contents'); $this->filesystem->write('dirname/b.txt', 'contents'); $this->filesystem->write('dirname/c.txt', 'contents'); $this->filesystem->deleteDirectory('dir'); $this->assertTrue($this->filesystem->fileExists('dirname/a.txt')); $this->filesystem->deleteDirectory('dirname'); $this->assertFalse($this->filesystem->fileExists('dirname/a.txt')); $this->assertFalse($this->filesystem->fileExists('dirname/b.txt')); $this->assertFalse($this->filesystem->fileExists('dirname/c.txt')); } /** * @test */ public function listing_directory_contents(): void { $this->filesystem->write('dirname/a.txt', 'contents'); $this->filesystem->write('dirname/b.txt', 'contents'); $this->filesystem->write('dirname/c.txt', 'contents'); $listing = $this->filesystem->listContents('', false); $this->assertInstanceOf(DirectoryListing::class, $listing); $this->assertInstanceOf(IteratorAggregate::class, $listing); $attributeListing = iterator_to_array($listing); $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $attributeListing); $this->assertCount(1, $attributeListing); } /** * @test */ public function listing_directory_contents_recursive(): void { $this->filesystem->write('dirname/a.txt', 'contents'); $this->filesystem->write('dirname/b.txt', 'contents'); $this->filesystem->write('dirname/c.txt', 'contents'); $listing = $this->filesystem->listContents('', true); $attributeListing = $listing->toArray(); $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $attributeListing); $this->assertCount(4, $attributeListing); } /** * @test */ public function copying_files(): void { $this->filesystem->write('path.txt', 'contents'); $this->filesystem->copy('path.txt', 'new-path.txt'); $this->assertTrue($this->filesystem->fileExists('path.txt')); $this->assertTrue($this->filesystem->fileExists('new-path.txt')); } /** * @test */ public function moving_files(): void { $this->filesystem->write('path.txt', 'contents'); $this->filesystem->move('path.txt', 'new-path.txt'); $this->assertFalse($this->filesystem->fileExists('path.txt')); $this->assertTrue($this->filesystem->fileExists('new-path.txt')); } /** * @test */ public function fetching_last_modified(): void { $this->filesystem->write('path.txt', 'contents'); $lastModified = $this->filesystem->lastModified('path.txt'); $this->assertIsInt($lastModified); $this->assertTrue($lastModified > time() - 30); $this->assertTrue($lastModified < time() + 30); } /** * @test */ public function fetching_mime_type(): void { $this->filesystem->write('path.txt', 'contents'); $mimeType = $this->filesystem->mimeType('path.txt'); $this->assertEquals('text/plain', $mimeType); } /** * @test */ public function fetching_file_size(): void { $this->filesystem->write('path.txt', 'contents'); $fileSize = $this->filesystem->fileSize('path.txt'); $this->assertEquals(8, $fileSize); } /** * @test */ public function ensuring_streams_are_rewound_when_writing(): void { $writeStream = stream_with_contents('contents'); fseek($writeStream, 4); $this->filesystem->writeStream('path.txt', $writeStream); $contents = $this->filesystem->read('path.txt'); $this->assertEquals('contents', $contents); } /** * @test */ public function setting_visibility(): void { $this->filesystem->write('path.txt', 'contents'); $this->filesystem->setVisibility('path.txt', Visibility::PUBLIC); $publicVisibility = $this->filesystem->visibility('path.txt'); $this->filesystem->setVisibility('path.txt', Visibility::PRIVATE); $privateVisibility = $this->filesystem->visibility('path.txt'); $this->assertEquals(Visibility::PUBLIC, $publicVisibility); $this->assertEquals(Visibility::PRIVATE, $privateVisibility); } /** * @test * * @dataProvider scenariosCausingPathTraversal */ public function protecting_against_path_traversals(callable $scenario): void { $this->expectException(PathTraversalDetected::class); $scenario($this->filesystem); } public static function scenariosCausingPathTraversal(): Generator { yield [function (FilesystemOperator $filesystem) { $filesystem->delete('../path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->deleteDirectory('../path'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->createDirectory('../path'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->read('../path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->readStream('../path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->write('../path.txt', 'contents'); }]; yield [function (FilesystemOperator $filesystem) { $stream = stream_with_contents('contents'); try { $filesystem->writeStream('../path.txt', $stream); } finally { fclose($stream); } }]; yield [function (FilesystemOperator $filesystem) { $filesystem->listContents('../path'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->fileExists('../path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->mimeType('../path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->fileSize('../path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->lastModified('../path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->visibility('../path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->setVisibility('../path.txt', Visibility::PUBLIC); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->copy('../path.txt', 'path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->copy('path.txt', '../path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->move('../path.txt', 'path.txt'); }]; yield [function (FilesystemOperator $filesystem) { $filesystem->move('path.txt', '../path.txt'); }]; } /** * @test */ public function listing_exceptions_are_uniformely_represented(): void { $filesystem = new Filesystem( new class() extends InMemoryFilesystemAdapter { public function listContents(string $path, bool $deep): iterable { yield from parent::listContents($path, $deep); throw new LogicException('Oh no.'); } } ); $items = $filesystem->listContents('', true); $this->expectException(UnableToListContents::class); iterator_to_array($items); // force the yields } /** * @test */ public function failing_to_create_a_public_url(): void { $filesystem = new Filesystem( new class() extends InMemoryFilesystemAdapter implements PublicUrlGenerator { public function publicUrl(string $path, Config $config): string { throw new UnableToGeneratePublicUrl('No reason', $path); } } ); $this->expectException(UnableToGeneratePublicUrl::class); $filesystem->publicUrl('path.txt'); } /** * @test */ public function not_configuring_a_public_url(): void { $filesystem = new Filesystem(new InMemoryFilesystemAdapter()); $this->expectException(UnableToGeneratePublicUrl::class); $filesystem->publicUrl('path.txt'); } /** * @test */ public function creating_a_public_url(): void { $filesystem = new Filesystem( new InMemoryFilesystemAdapter(), ['public_url' => 'https://example.org/public/'], ); $url = $filesystem->publicUrl('path.txt'); self::assertEquals('https://example.org/public/path.txt', $url); } /** * @test */ public function public_url_array_uses_multi_prefixer(): void { $filesystem = new Filesystem( new InMemoryFilesystemAdapter(), ['public_url' => ['https://cdn1', 'https://cdn2']], ); $url1 = $filesystem->publicUrl('first-path1.txt'); $url2 = $filesystem->publicUrl('path2.txt'); $url3 = $filesystem->publicUrl('first-path1.txt'); // deterministic $url4 = $filesystem->publicUrl('/some/path-here.txt'); $url5 = $filesystem->publicUrl('some/path-here.txt'); // deterministic even with leading "/" self::assertEquals('https://cdn1/first-path1.txt', $url1); self::assertEquals('https://cdn2/path2.txt', $url2); self::assertEquals('https://cdn1/first-path1.txt', $url3); self::assertEquals('https://cdn2/some/path-here.txt', $url4); self::assertEquals('https://cdn2/some/path-here.txt', $url5); } /** * @test */ public function custom_public_url_generator(): void { $filesystem = new Filesystem( new InMemoryFilesystemAdapter(), [], publicUrlGenerator: new class() implements PublicUrlGenerator { public function publicUrl(string $path, Config $config): string { return 'custom/' . $path; } }, ); self::assertSame('custom/file.txt', $filesystem->publicUrl('file.txt')); } /** * @test */ public function copying_from_and_to_the_same_location_fails(): void { $this->expectExceptionObject(UnableToCopyFile::sourceAndDestinationAreTheSame('from.txt', 'from.txt')); $config = [Config::OPTION_COPY_IDENTICAL_PATH => ResolveIdenticalPathConflict::FAIL]; $this->filesystem->copy('from.txt', 'from.txt', $config); } /** * @test */ public function moving_from_and_to_the_same_location_fails(): void { $this->expectExceptionObject(UnableToMoveFile::fromLocationTo('from.txt', 'from.txt')); $config = [Config::OPTION_MOVE_IDENTICAL_PATH => ResolveIdenticalPathConflict::FAIL]; $this->filesystem->move('from.txt', 'from.txt', $config); } /** * @test */ public function get_checksum_for_adapter_that_supports(): void { $this->filesystem->write('path.txt', 'foobar'); $this->assertSame('3858f62230ac3c915f300c664312c63f', $this->filesystem->checksum('path.txt')); } /** * @test */ public function get_checksum_for_adapter_that_does_not_support(): void { $filesystem = new Filesystem(new InMemoryFilesystemAdapter()); $filesystem->write('path.txt', 'foobar'); $this->assertSame('3858f62230ac3c915f300c664312c63f', $filesystem->checksum('path.txt')); } /** * @test */ public function get_checksum_for_adapter_that_does_not_support_specific_algo(): void { $adapter = new class() extends InMemoryFilesystemAdapter implements ChecksumProvider { public function checksum(string $path, Config $config): string { throw new ChecksumAlgoIsNotSupported(); } }; $filesystem = new Filesystem($adapter); $filesystem->write('path.txt', 'foobar'); $this->assertSame('3858f62230ac3c915f300c664312c63f', $filesystem->checksum('path.txt')); } /** * @test */ public function get_sha256_checksum_for_adapter_that_does_not_support(): void { $filesystem = new Filesystem(new InMemoryFilesystemAdapter(), ['checksum_algo' => 'sha256']); $filesystem->write('path.txt', 'foobar'); $this->assertSame('c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2', $filesystem->checksum('path.txt')); } /** * @test */ public function get_sha256_checksum_for_adapter_that_does_not_support_while_crc32c_is_the_default(): void { $filesystem = new Filesystem(new InMemoryFilesystemAdapter(), ['checksum_algo' => 'crc32c']); $filesystem->write('path.txt', 'foobar'); $checksum = $filesystem->checksum('path.txt', ['checksum_algo' => 'sha256']); $this->assertSame('c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2', $checksum); } /** * @test */ public function unable_to_get_checksum_for_for_file_that_does_not_exist(): void { $filesystem = new Filesystem(new InMemoryFilesystemAdapter()); $this->expectException(UnableToProvideChecksum::class); $filesystem->checksum('path.txt'); } /** * @test */ public function generating_temporary_urls(): void { $filesystem = new Filesystem( new InMemoryFilesystemAdapter(), temporaryUrlGenerator: new class() implements TemporaryUrlGenerator { public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string { return 'https://flysystem.thephpleague.com/' . $path . '?exporesAt=' . $expiresAt->format('U'); } } ); $now = \time(); $temporaryUrl = $filesystem->temporaryUrl('some/file.txt', new DateTimeImmutable('@' . $now)); $expectedUrl = 'https://flysystem.thephpleague.com/some/file.txt?exporesAt=' . $now; self::assertEquals($expectedUrl, $temporaryUrl); } /** * @test */ public function not_being_able_to_generate_temporary_urls(): void { $filesystem = new Filesystem(new InMemoryFilesystemAdapter()); $this->expectException(UnableToGenerateTemporaryUrl::class); $filesystem->temporaryUrl('some/file.txt', new DateTimeImmutable()); } /** * @test */ public function ignoring_same_paths_for_move_and_copy(): void { $this->expectNotToPerformAssertions(); $filesystem = new Filesystem( new InMemoryFilesystemAdapter(), [ Config::OPTION_COPY_IDENTICAL_PATH => ResolveIdenticalPathConflict::IGNORE, Config::OPTION_MOVE_IDENTICAL_PATH => ResolveIdenticalPathConflict::IGNORE, ] ); $filesystem->move('from.txt', 'from.txt'); $filesystem->copy('from.txt', 'from.txt'); } /** * @test */ public function failing_same_paths_for_move(): void { $filesystem = new Filesystem( new InMemoryFilesystemAdapter(), [ Config::OPTION_MOVE_IDENTICAL_PATH => ResolveIdenticalPathConflict::FAIL, ] ); $this->expectExceptionObject(UnableToMoveFile::fromLocationTo('from.txt', 'from.txt')); $filesystem->move('from.txt', 'from.txt'); } /** * @test */ public function failing_same_paths_for_copy(): void { $filesystem = new Filesystem( new InMemoryFilesystemAdapter(), [ Config::OPTION_COPY_IDENTICAL_PATH => ResolveIdenticalPathConflict::FAIL, ] ); $this->expectExceptionObject(UnableToCopyFile::fromLocationTo('from.txt', 'from.txt')); $filesystem->copy('from.txt', 'from.txt'); } /** * @test */ public function unable_to_get_checksum_directory(): void { $filesystem = new Filesystem(new InMemoryFilesystemAdapter()); $filesystem->createDirectory('foo'); $this->expectException(UnableToProvideChecksum::class); $filesystem->checksum('foo'); } /** * @test * * @dataProvider fileMoveOrCopyScenarios */ public function moving_a_file_with_visibility_scenario( array $mainConfig, array $moveConfig, ?string $writeVisibility, string $expectedVisibility ): void { // arrange $filesystem = new Filesystem( new InMemoryFilesystemAdapter(), $mainConfig ); $writeConfig = $writeVisibility ? ['visibility' => $writeVisibility] : []; $filesystem->write('from.txt', 'contents', $writeConfig); // act $filesystem->move('from.txt', 'to.txt', $moveConfig); // assert $this->assertEquals($expectedVisibility, $filesystem->visibility('to.txt')); } /** * @test * * @dataProvider fileMoveOrCopyScenarios */ public function copying_a_file_with_visibility_scenario( array $mainConfig, array $copyConfig, ?string $writeVisibility, string $expectedVisibility ): void { // arrange $filesystem = new Filesystem( new InMemoryFilesystemAdapter(), $mainConfig ); $writeConfig = $writeVisibility ? ['visibility' => $writeVisibility] : []; $filesystem->write('from.txt', 'contents', $writeConfig); // act $filesystem->copy('from.txt', 'to.txt', $copyConfig); // assert $this->assertEquals($expectedVisibility, $filesystem->visibility('to.txt')); } public static function fileMoveOrCopyScenarios(): iterable { yield 'retain visibility, write default, default private' => [ ['retain_visibility' => true, 'visibility' => 'private'], [], null, 'private' ]; yield 'retain visibility, write default, default public' => [ ['retain_visibility' => true, 'visibility' => 'public'], [], null, 'public' ]; yield 'retain visibility, write public, default private' => [ ['retain_visibility' => true, 'visibility' => 'private'], [], 'public', 'public' ]; yield 'retain visibility, write private, default public' => [ ['retain_visibility' => true, 'visibility' => 'public'], [], 'private', 'private' ]; yield 'retain visibility, write default, default private, execute public' => [ ['retain_visibility' => true, 'visibility' => 'private'], ['visibility' => 'public'], null, 'public' ]; yield 'retain visibility, write default, default public, execute private' => [ ['retain_visibility' => true, 'visibility' => 'public'], ['visibility' => 'private'], null, 'private' ]; yield 'retain visibility, write public, default private, execute private' => [ ['retain_visibility' => true, 'visibility' => 'private'], ['visibility' => 'private'], 'public', 'private' ]; yield 'retain visibility, write private, default public, execute public' => [ ['retain_visibility' => true, 'visibility' => 'public'], ['visibility' => 'public'], 'private', 'public' ]; yield 'do not retain visibility, write default, default private' => [ ['retain_visibility' => false, 'visibility' => 'private'], [], null, 'private' ]; yield 'do not retain visibility, write default, default public' => [ ['retain_visibility' => false, 'visibility' => 'public'], [], null, 'public' ]; yield 'do not retain visibility, write public, default private' => [ ['retain_visibility' => false, 'visibility' => 'private'], [], 'public', 'private' ]; yield 'do not retain visibility, write private, default public' => [ ['retain_visibility' => false, 'visibility' => 'public'], [], 'private', 'public' ]; yield 'do not retain visibility, write default, default private, execute public' => [ ['retain_visibility' => false, 'visibility' => 'private'], ['visibility' => 'public'], null, 'public' ]; yield 'do not retain visibility, write default, default public, execute private' => [ ['retain_visibility' => false, 'visibility' => 'public'], ['visibility' => 'private'], null, 'private' ]; yield 'do not retain visibility, write public, default private, execute public' => [ ['retain_visibility' => false, 'visibility' => 'private'], ['visibility' => 'public'], 'public', 'public' ]; yield 'do not retain visibility, write private, default public, execute private' => [ ['retain_visibility' => false, 'visibility' => 'public'], ['visibility' => 'private'], 'private', 'private' ]; } } ================================================ FILE: src/FilesystemWriter.php ================================================ failNextCall = true; } /** * @inheritDoc */ public function isConnected($connection): bool { if ($this->failNextCall) { $this->failNextCall = false; return false; } return $this->connectivityChecker->isConnected($connection); } } ================================================ FILE: src/Ftp/FtpAdapter.php ================================================ systemType = $this->connectionOptions->systemType(); $this->connectionProvider = $connectionProvider ?? new FtpConnectionProvider(); $this->connectivityChecker = $connectivityChecker ?? new NoopCommandConnectivityChecker(); $this->visibilityConverter = $visibilityConverter ?? new PortableVisibilityConverter(); $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); $this->useRawListOptions = $connectionOptions->useRawListOptions(); } /** * Disconnect FTP connection on destruct. */ public function __destruct() { $this->disconnect(); } /** * @return resource */ private function connection() { start: if ( ! $this->hasFtpConnection()) { $this->connection = $this->connectionProvider->createConnection($this->connectionOptions); $this->rootDirectory = $this->resolveConnectionRoot($this->connection); $this->prefixer = new PathPrefixer($this->rootDirectory); return $this->connection; } if ($this->connectivityChecker->isConnected($this->connection) === false) { $this->connection = false; goto start; } ftp_chdir($this->connection, $this->rootDirectory); return $this->connection; } public function disconnect(): void { if ($this->hasFtpConnection()) { @ftp_close($this->connection); } $this->connection = false; } private function isPureFtpdServer(): bool { if ($this->isPureFtpdServer !== null) { return $this->isPureFtpdServer; } $response = ftp_raw($this->connection, 'HELP'); return $this->isPureFtpdServer = stripos(implode(' ', $response), 'Pure-FTPd') !== false; } private function isServerSupportingListOptions(): bool { if ($this->useRawListOptions !== null) { return $this->useRawListOptions; } $response = ftp_raw($this->connection, 'SYST'); $syst = implode(' ', $response); return $this->useRawListOptions = stripos($syst, 'FileZilla') === false && stripos($syst, 'L8') === false; } public function fileExists(string $path): bool { try { $this->fileSize($path); return true; } catch (UnableToRetrieveMetadata $exception) { return false; } } public function write(string $path, string $contents, Config $config): void { try { $writeStream = fopen('php://temp', 'w+b'); fwrite($writeStream, $contents); rewind($writeStream); $this->writeStream($path, $writeStream, $config); } finally { isset($writeStream) && is_resource($writeStream) && fclose($writeStream); } } public function writeStream(string $path, $contents, Config $config): void { try { $this->ensureParentDirectoryExists($path, $config->get(Config::OPTION_DIRECTORY_VISIBILITY)); } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, 'creating parent directory failed', $exception); } $location = $this->prefixer()->prefixPath($path); if ( ! ftp_fput($this->connection(), $location, $contents, $this->connectionOptions->transferMode())) { throw UnableToWriteFile::atLocation($path, 'writing the file failed'); } if ( ! $visibility = $config->get(Config::OPTION_VISIBILITY)) { return; } try { $this->setVisibility($path, $visibility); } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, 'setting visibility failed', $exception); } } public function read(string $path): string { $readStream = $this->readStream($path); $contents = stream_get_contents($readStream); fclose($readStream); return $contents; } public function readStream(string $path) { $location = $this->prefixer()->prefixPath($path); $stream = fopen('php://temp', 'w+b'); $result = @ftp_fget($this->connection(), $stream, $location, $this->connectionOptions->transferMode()); if ( ! $result) { fclose($stream); throw UnableToReadFile::fromLocation($path, error_get_last()['message'] ?? ''); } rewind($stream); return $stream; } public function delete(string $path): void { $connection = $this->connection(); $this->deleteFile($path, $connection); } /** * @param resource $connection */ private function deleteFile(string $path, $connection): void { $location = $this->prefixer()->prefixPath($path); $success = @ftp_delete($connection, $location); if ($success === false && ftp_size($connection, $location) !== -1) { throw UnableToDeleteFile::atLocation($path, 'the file still exists'); } } public function deleteDirectory(string $path): void { /** @var StorageAttributes[] $contents */ $contents = $this->listContents($path, true); $connection = $this->connection(); $directories = [$path]; foreach ($contents as $item) { if ($item->isDir()) { $directories[] = $item->path(); continue; } try { $this->deleteFile($item->path(), $connection); } catch (Throwable $exception) { throw UnableToDeleteDirectory::atLocation($path, 'unable to delete child', $exception); } } rsort($directories); foreach ($directories as $directory) { if ( ! @ftp_rmdir($connection, $this->prefixer()->prefixPath($directory))) { throw UnableToDeleteDirectory::atLocation($path, "Could not delete directory $directory"); } } } public function createDirectory(string $path, Config $config): void { $this->ensureDirectoryExists($path, $config->get(Config::OPTION_DIRECTORY_VISIBILITY, $config->get(Config::OPTION_VISIBILITY))); } public function setVisibility(string $path, string $visibility): void { $location = $this->prefixer()->prefixPath($path); $mode = $this->visibilityConverter->forFile($visibility); if ( ! @ftp_chmod($this->connection(), $mode, $location)) { $message = error_get_last()['message'] ?? ''; throw UnableToSetVisibility::atLocation($path, $message); } } private function fetchMetadata(string $path, string $type): FileAttributes { $location = $this->prefixer()->prefixPath($path); if ($this->isPureFtpdServer) { $location = $this->escapePath($location); } $object = @ftp_raw($this->connection(), 'STAT ' . $location); if (empty($object) || count($object) < 3 || str_starts_with($object[1], "ftpd:")) { throw UnableToRetrieveMetadata::create($path, $type, error_get_last()['message'] ?? ''); } $attributes = $this->normalizeObject($object[1], ''); if ( ! $attributes instanceof FileAttributes) { throw UnableToRetrieveMetadata::create( $path, $type, 'expected file, ' . ($attributes instanceof DirectoryAttributes ? 'directory found' : 'nothing found') ); } return $attributes; } public function mimeType(string $path): FileAttributes { try { $mimetype = $this->detectMimeTypeUsingPath ? $this->mimeTypeDetector->detectMimeTypeFromPath($path) : $this->mimeTypeDetector->detectMimeType($path, $this->read($path)); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::mimeType($path, $exception->getMessage(), $exception); } if ($mimetype === null) { throw UnableToRetrieveMetadata::mimeType($path, 'Unknown.'); } return new FileAttributes($path, null, null, null, $mimetype); } public function lastModified(string $path): FileAttributes { $location = $this->prefixer()->prefixPath($path); $connection = $this->connection(); $lastModified = @ftp_mdtm($connection, $location); if ($lastModified < 0) { throw UnableToRetrieveMetadata::lastModified($path); } return new FileAttributes($path, null, null, $lastModified); } public function visibility(string $path): FileAttributes { return $this->fetchMetadata($path, FileAttributes::ATTRIBUTE_VISIBILITY); } public function fileSize(string $path): FileAttributes { $location = $this->prefixer()->prefixPath($path); $connection = $this->connection(); $fileSize = @ftp_size($connection, $location); if ($fileSize < 0) { throw UnableToRetrieveMetadata::fileSize($path, error_get_last()['message'] ?? ''); } return new FileAttributes($path, $fileSize); } public function listContents(string $path, bool $deep): iterable { $path = ltrim($path, '/'); $path = $path === '' ? $path : trim($path, '/') . '/'; if ($deep && $this->connectionOptions->recurseManually()) { yield from $this->listDirectoryContentsRecursive($path); } else { $location = $this->prefixer()->prefixPath($path); $options = $deep ? '-alnR' : '-aln'; $listing = $this->ftpRawlist($options, $location); yield from $this->normalizeListing($listing, $path); } } private function normalizeListing(array $listing, string $prefix = ''): Generator { $base = $prefix; foreach ($listing as $item) { if ($item === '' || preg_match('#.* \.(\.)?$|^total#', $item)) { continue; } if (preg_match('#^.*:$#', $item)) { $base = preg_replace('~^\./*|:$~', '', $item); continue; } yield $this->normalizeObject($item, $base); } } private function normalizeObject(string $item, string $base): StorageAttributes { $this->systemType === null && $this->systemType = $this->detectSystemType($item); if ($this->systemType === self::SYSTEM_TYPE_UNIX) { return $this->normalizeUnixObject($item, $base); } return $this->normalizeWindowsObject($item, $base); } private function detectSystemType(string $item): string { return preg_match( '/^[0-9]{2,4}-[0-9]{2}-[0-9]{2}/', $item ) ? self::SYSTEM_TYPE_WINDOWS : self::SYSTEM_TYPE_UNIX; } private function normalizeWindowsObject(string $item, string $base): StorageAttributes { $item = preg_replace('#\s+#', ' ', trim($item), 3); $parts = explode(' ', $item, 4); if (count($parts) !== 4) { throw new InvalidListResponseReceived("Metadata can't be parsed from item '$item' , not enough parts."); } [$date, $time, $size, $name] = $parts; $path = $base === '' ? $name : rtrim($base, '/') . '/' . $name; if ($size === '') { return new DirectoryAttributes($path); } // Check for the correct date/time format $format = strlen($date) === 8 ? 'm-d-yH:iA' : 'Y-m-dH:i'; $dt = DateTime::createFromFormat($format, $date . $time); $lastModified = $dt ? $dt->getTimestamp() : (int) strtotime("$date $time"); return new FileAttributes($path, (int) $size, null, $lastModified); } private function normalizeUnixObject(string $item, string $base): StorageAttributes { $item = preg_replace('#\s+#', ' ', trim($item), 7); $parts = explode(' ', $item, 9); if (count($parts) !== 9) { throw new InvalidListResponseReceived("Metadata can't be parsed from item '$item' , not enough parts."); } [$permissions, /* $number */, /* $owner */, /* $group */, $size, $month, $day, $timeOrYear, $name] = $parts; $isDirectory = $this->listingItemIsDirectory($permissions); $permissions = $this->normalizePermissions($permissions); $path = $base === '' ? $name : rtrim($base, '/') . '/' . $name; $lastModified = $this->connectionOptions->timestampsOnUnixListingsEnabled() ? $this->normalizeUnixTimestamp( $month, $day, $timeOrYear ) : null; if ($isDirectory) { return new DirectoryAttributes( $path, $this->visibilityConverter->inverseForDirectory($permissions), $lastModified ); } $visibility = $this->visibilityConverter->inverseForFile($permissions); return new FileAttributes($path, (int) $size, $visibility, $lastModified); } private function listingItemIsDirectory(string $permissions): bool { return str_starts_with($permissions, 'd'); } private function normalizeUnixTimestamp(string $month, string $day, string $timeOrYear): int { if (is_numeric($timeOrYear)) { $year = $timeOrYear; $hour = '00'; $minute = '00'; } else { $year = date('Y'); [$hour, $minute] = explode(':', $timeOrYear); } $dateTime = DateTime::createFromFormat('Y-M-j-G:i:s', "$year-$month-$day-$hour:$minute:00"); return $dateTime->getTimestamp(); } private function normalizePermissions(string $permissions): int { // remove the type identifier $permissions = substr($permissions, 1); // map the string rights to the numeric counterparts $map = ['-' => '0', 'r' => '4', 'w' => '2', 'x' => '1']; $permissions = strtr($permissions, $map); // split up the permission groups $parts = str_split($permissions, 3); // convert the groups $mapper = static function ($part) { return array_sum(array_map(static function ($p) { return (int) $p; }, str_split($part))); }; // converts to decimal number return octdec(implode('', array_map($mapper, $parts))); } private function listDirectoryContentsRecursive(string $directory): Generator { $location = $this->prefixer()->prefixPath($directory); $listing = $this->ftpRawlist('-aln', $location); /** @var StorageAttributes[] $listing */ $listing = $this->normalizeListing($listing, $directory); foreach ($listing as $item) { yield $item; if ( ! $item->isDir()) { continue; } $children = $this->listDirectoryContentsRecursive($item->path()); foreach ($children as $child) { yield $child; } } } private function ftpRawlist(string $options, string $path): array { $path = rtrim($path, '/') . '/'; $connection = $this->connection(); if ($this->isPureFtpdServer()) { $path = str_replace(' ', '\ ', $path); $path = $this->escapePath($path); } if ( ! $this->isServerSupportingListOptions()) { $options = ''; } return ftp_rawlist($connection, ($options ? $options . ' ' : '') . $path, stripos($options, 'R') !== false) ?: []; } public function move(string $source, string $destination, Config $config): void { try { $this->ensureParentDirectoryExists($destination, $config->get(Config::OPTION_DIRECTORY_VISIBILITY)); } catch (Throwable $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } $sourceLocation = $this->prefixer()->prefixPath($source); $destinationLocation = $this->prefixer()->prefixPath($destination); $connection = $this->connection(); if ( ! @ftp_rename($connection, $sourceLocation, $destinationLocation)) { throw UnableToMoveFile::because(error_get_last()['message'] ?? 'reason unknown', $source, $destination); } } public function copy(string $source, string $destination, Config $config): void { try { $readStream = $this->readStream($source); $visibility = $config->get(Config::OPTION_VISIBILITY); if ($visibility === null && $config->get(Config::OPTION_RETAIN_VISIBILITY, true)) { $config = $config->withSetting(Config::OPTION_VISIBILITY, $this->visibility($source)->visibility()); } $this->writeStream($destination, $readStream, $config); } catch (Throwable $exception) { if (isset($readStream) && is_resource($readStream)) { @fclose($readStream); } throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } private function ensureParentDirectoryExists(string $path, ?string $visibility): void { $dirname = dirname($path); if ($dirname === '' || $dirname === '.') { return; } $this->ensureDirectoryExists($dirname, $visibility); } private function ensureDirectoryExists(string $dirname, ?string $visibility): void { $connection = $this->connection(); $dirPath = ''; $parts = explode('/', trim($dirname, '/')); $mode = $visibility ? $this->visibilityConverter->forDirectory($visibility) : false; foreach ($parts as $part) { $dirPath .= '/' . $part; $location = $this->prefixer()->prefixPath($dirPath); if (@ftp_chdir($connection, $location)) { continue; } error_clear_last(); $result = @ftp_mkdir($connection, $location); if ($result === false) { $errorMessage = error_get_last()['message'] ?? 'unable to create the directory'; throw UnableToCreateDirectory::atLocation($dirPath, $errorMessage); } if ($mode !== false && @ftp_chmod($connection, $mode, $location) === false) { throw UnableToCreateDirectory::atLocation( $dirPath, 'unable to chmod the directory: ' . (error_get_last()['message'] ?? 'reason unknown'), ); } } } private function escapePath(string $path): string { return str_replace(['*', '[', ']'], ['\\*', '\\[', '\\]'], $path); } /** * @return bool */ private function hasFtpConnection(): bool { return $this->connection instanceof \FTP\Connection || is_resource($this->connection); } public function directoryExists(string $path): bool { $location = $this->prefixer()->prefixPath($path); $connection = $this->connection(); return @ftp_chdir($connection, $location) === true; } /** * @param resource|\FTP\Connection $connection */ private function resolveConnectionRoot($connection): string { $root = $this->connectionOptions->root(); error_clear_last(); if ($root !== '' && @ftp_chdir($connection, $root) !== true) { throw UnableToResolveConnectionRoot::itDoesNotExist($root, error_get_last()['message'] ?? ''); } error_clear_last(); $pwd = @ftp_pwd($connection); if ( ! is_string($pwd)) { throw UnableToResolveConnectionRoot::couldNotGetCurrentDirectory(error_get_last()['message'] ?? ''); } return $pwd; } /** * @return PathPrefixer */ private function prefixer(): PathPrefixer { if ($this->rootDirectory === null) { $this->connection(); } return $this->prefixer; } } ================================================ FILE: src/Ftp/FtpAdapterTest.php ================================================ 'localhost', 'port' => 2121, 'timestampsOnUnixListingsEnabled' => true, 'root' => '/home/foo/upload/', 'username' => 'foo', 'password' => 'pass', ]); static::$connectivityChecker = new ConnectivityCheckerThatCanFail(new NoopCommandConnectivityChecker()); static::$connectionProvider = new StubConnectionProvider(new FtpConnectionProvider()); return new FtpAdapter( $options, static::$connectionProvider, static::$connectivityChecker, ); } /** * @test */ public function disconnect_after_destruct(): void { /** @var FtpAdapter $adapter */ $adapter = $this->adapter(); $reflection = new \ReflectionObject($adapter); $adapter->fileExists('foo.txt'); $reflectionProperty = $reflection->getProperty('connection'); $reflectionProperty->setAccessible(true); $connection = $reflectionProperty->getValue($adapter); unset($reflection); $this->assertTrue(false !== ftp_pwd($connection)); $adapter->__destruct(); static::clearFilesystemAdapterCache(); $this->assertFalse((new NoopCommandConnectivityChecker())->isConnected($connection)); } /** * @test */ public function it_can_disconnect(): void { /** @var FtpAdapter $adapter */ $adapter = $this->adapter(); $this->assertFalse($adapter->fileExists('not-existing.file')); self::assertTrue(static::$connectivityChecker->isConnected(static::$connectionProvider->connection)); $adapter->disconnect(); self::assertFalse(static::$connectivityChecker->isConnected(static::$connectionProvider->connection)); } /** * @test */ public function not_being_able_to_resolve_connection_root(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'timestampsOnUnixListingsEnabled' => true, 'root' => '/invalid/root', 'username' => 'foo', 'password' => 'pass', ]); $adapter = new FtpAdapter($options); $this->expectExceptionObject(UnableToResolveConnectionRoot::itDoesNotExist('/invalid/root')); $adapter->delete('something'); } /** * @test */ public function not_being_able_to_resolve_connection_root_pwd(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'timestampsOnUnixListingsEnabled' => true, 'root' => '/home/foo/upload/', 'username' => 'foo', 'password' => 'pass', ]); $this->expectExceptionObject(UnableToResolveConnectionRoot::couldNotGetCurrentDirectory()); mock_function('ftp_pwd', false); $adapter = new FtpAdapter($options); $adapter->delete('something'); } protected function tearDown(): void { reset_function_mocks(); } } ================================================ FILE: src/Ftp/FtpAdapterTestCase.php ================================================ retryOnException(UnableToConnectToFtpHost::class); } protected static ConnectivityCheckerThatCanFail $connectivityChecker; protected static ?StubConnectionProvider $connectionProvider; /** * @after */ public function resetFunctionMocks(): void { reset_function_mocks(); } public static function clearFilesystemAdapterCache(): void { parent::clearFilesystemAdapterCache(); static::$connectionProvider = null; } /** * @test */ public function using_empty_string_for_root(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'root' => '', 'username' => 'foo', 'password' => 'pass', ]); $this->runScenario(function () use ($options) { $adapter = new FtpAdapter($options); $adapter->write('dirname1/dirname2/path.txt', 'contents', new Config()); $adapter->write('dirname1/dirname2/path.txt', 'contents', new Config()); $this->assertTrue($adapter->fileExists('dirname1/dirname2/path.txt')); $this->assertSame('contents', $adapter->read('dirname1/dirname2/path.txt')); }); } /** * @test */ public function reconnecting_after_failure(): void { $this->runScenario(function () { $adapter = $this->adapter(); static::$connectivityChecker->failNextCall(); $contents = iterator_to_array($adapter->listContents('', false)); $this->assertIsArray($contents); }); } /** * @test * * @see https://github.com/thephpleague/flysystem/issues/1522 */ public function reading_a_file_twice_for_issue_1522(): void { $this->givenWeHaveAnExistingFile('some/nested/path.txt', 'this is it'); $this->runScenario(function () { $adapter = $this->adapter(); self::assertEquals('this is it', $adapter->read('some/nested/path.txt')); self::assertEquals('this is it', $adapter->read('some/nested/path.txt')); self::assertEquals('this is it', $adapter->read('some/nested/path.txt')); }); } /** * @test * * @dataProvider scenariosCausingWriteFailure */ public function failing_to_write_a_file(callable $scenario): void { $this->runScenario(function () use ($scenario) { $scenario(); }); $this->expectException(UnableToWriteFile::class); $this->runScenario(function () { $this->adapter()->write('some/path.txt', 'contents', new Config([ Config::OPTION_VISIBILITY => Visibility::PUBLIC, Config::OPTION_DIRECTORY_VISIBILITY => Visibility::PUBLIC ])); }); } public static function scenariosCausingWriteFailure(): Generator { yield "Not being able to create the parent directory" => [function () { mock_function('ftp_mkdir', false); }]; yield "Not being able to set the parent directory visibility" => [function () { mock_function('ftp_chmod', false); }]; yield "Not being able to write the file" => [function () { mock_function('ftp_fput', false); }]; yield "Not being able to set the visibility" => [function () { mock_function('ftp_chmod', true, false); }]; } /** * @test * * @dataProvider scenariosCausingDirectoryDeleteFailure */ public function scenarios_causing_directory_deletion_to_fail(callable $scenario): void { $this->runScenario($scenario); $this->givenWeHaveAnExistingFile('some/nested/path.txt'); $this->expectException(UnableToDeleteDirectory::class); $this->runScenario(function () { $this->adapter()->deleteDirectory('some'); }); } public static function scenariosCausingDirectoryDeleteFailure(): Generator { yield "ftp_delete failure" => [function () { mock_function('ftp_delete', false); }]; yield "ftp_rmdir failure" => [function () { mock_function('ftp_rmdir', false); }]; } /** * @test * * @dataProvider scenariosCausingCopyFailure */ public function failing_to_copy(callable $scenario): void { $this->givenWeHaveAnExistingFile('path.txt'); $scenario(); $this->expectException(UnableToCopyFile::class); $this->runScenario(function () { $this->adapter()->copy('path.txt', 'new/path.txt', new Config()); }); } /** * @test */ public function failing_to_move_because_creating_the_directory_fails(): void { $this->givenWeHaveAnExistingFile('path.txt'); mock_function('ftp_mkdir', false); $this->expectException(UnableToMoveFile::class); $this->runScenario(function () { $this->adapter()->move('path.txt', 'new/path.txt', new Config()); }); } public static function scenariosCausingCopyFailure(): Generator { yield "failing to read" => [function () { mock_function('ftp_fget', false); }]; yield "failing to write" => [function () { mock_function('ftp_fput', false); }]; } /** * @test */ public function failing_to_delete_a_file(): void { $this->givenWeHaveAnExistingFile('path.txt', 'contents'); mock_function('ftp_delete', false); $this->expectException(UnableToDeleteFile::class); $this->runScenario(function () { $this->adapter()->delete('path.txt'); }); } /** * @test */ public function formatting_a_directory_listing_with_a_total_indicator(): void { $response = [ 'total 1', '-rw-r--r-- 1 ftp ftp 409 Aug 19 09:01 file1.txt', ]; mock_function('ftp_rawlist', $response); $this->runScenario(function () { $adapter = $this->adapter(); $contents = iterator_to_array($adapter->listContents('/', false), false); $this->assertCount(1, $contents); $this->assertContainsOnlyInstancesOf(FileAttributes::class, $contents); }); } /** * @test * * @runInSeparateProcess */ public function receiving_a_windows_listing(): void { $response = [ '2015-05-23 12:09 dir1', '05-23-15 12:09PM 684 file2.txt', ]; mock_function('ftp_rawlist', $response); $this->runScenario(function () { $adapter = $this->adapter(); $contents = iterator_to_array($adapter->listContents('/', false), false); $this->assertCount(2, $contents); $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $contents); }); } /** * @test */ public function receiving_an_invalid_windows_listing(): void { $response = [ '05-23-15 12:09PM file2.txt', ]; mock_function('ftp_rawlist', $response); $this->expectException(InvalidListResponseReceived::class); $this->runScenario(function () { $adapter = $this->adapter(); iterator_to_array($adapter->listContents('/', false), false); }); } /** * @test */ public function getting_an_invalid_listing_response_for_unix_listings(): void { $response = [ 'total 1', '-rw-r--r-- 1 ftp 409 Aug 19 09:01 file1.txt', ]; mock_function('ftp_rawlist', $response); $this->expectException(InvalidListResponseReceived::class); $this->runScenario(function () { $adapter = $this->adapter(); iterator_to_array($adapter->listContents('/', false), false); }); } /** * @test */ public function failing_to_get_the_file_size_of_a_directory(): void { $adapter = $this->adapter(); $this->runScenario(function () use ($adapter) { $adapter->createDirectory('directory_name', new Config()); }); $this->expectException(UnableToRetrieveMetadata::class); $this->runScenario(function () use ($adapter) { $adapter->fileSize('directory_name'); }); } /** * @test */ public function formatting_non_manual_recursive_listings(): void { $response = [ 'drwxr-xr-x 4 ftp ftp 4096 Nov 24 13:58 .', 'drwxr-xr-x 16 ftp ftp 4096 Sep 2 13:01 ..', 'drwxr-xr-x 2 ftp ftp 4096 Oct 13 2012 cgi-bin', 'drwxr-xr-x 2 ftp ftp 4096 Nov 24 13:59 folder', '-rw-r--r-- 1 ftp ftp 409 Oct 13 2012 index.html', '', 'somewhere/cgi-bin:', 'drwxr-xr-x 2 ftp ftp 4096 Oct 13 2012 .', 'drwxr-xr-x 4 ftp ftp 4096 Nov 24 13:58 ..', '', 'somewhere/folder:', 'drwxr-xr-x 2 ftp ftp 4096 Nov 24 13:59 .', 'drwxr-xr-x 4 ftp ftp 4096 Nov 24 13:58 ..', '-rw-r--r-- 1 ftp ftp 0 Nov 24 13:59 dummy.txt', ]; mock_function('ftp_rawlist', $response); $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'timestampsOnUnixListingsEnabled' => true, 'recurseManually' => false, 'root' => '/home/foo/upload/', 'username' => 'foo', 'password' => 'pass', ]); $this->runScenario(function () use ($options) { $adapter = new FtpAdapter($options); $contents = iterator_to_array($adapter->listContents('somewhere', true), false); $this->assertCount(4, $contents); $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $contents); }); } /** * @test */ public function filenames_and_dirnames_with_spaces_are_supported(): void { $this->givenWeHaveAnExistingFile('some dirname/file name.txt'); $this->runScenario(function () { $adapter = $this->adapter(); $this->assertTrue($adapter->fileExists('some dirname/file name.txt')); $contents = iterator_to_array($adapter->listContents('', true)); $this->assertCount(2, $contents); $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $contents); }); } } ================================================ FILE: src/Ftp/FtpConnectionException.php ================================================ host; } public function root(): string { return $this->root; } public function username(): string { return $this->username; } public function password(): string { return $this->password; } public function port(): int { return $this->port; } public function ssl(): bool { return $this->ssl; } public function timeout(): int { return $this->timeout; } public function utf8(): bool { return $this->utf8; } public function passive(): bool { return $this->passive; } public function transferMode(): int { return $this->transferMode; } public function systemType(): ?string { return $this->systemType; } public function ignorePassiveAddress(): ?bool { return $this->ignorePassiveAddress; } public function timestampsOnUnixListingsEnabled(): bool { return $this->enableTimestampsOnUnixListings; } public function recurseManually(): bool { return $this->recurseManually; } public function useRawListOptions(): ?bool { return $this->useRawListOptions; } public static function fromArray(array $options): FtpConnectionOptions { return new FtpConnectionOptions( $options['host'] ?? 'invalid://host-not-set', $options['root'] ?? '', $options['username'] ?? 'invalid://username-not-set', $options['password'] ?? 'invalid://password-not-set', $options['port'] ?? 21, $options['ssl'] ?? false, $options['timeout'] ?? 90, $options['utf8'] ?? false, $options['passive'] ?? true, $options['transferMode'] ?? FTP_BINARY, $options['systemType'] ?? null, $options['ignorePassiveAddress'] ?? null, $options['timestampsOnUnixListingsEnabled'] ?? false, $options['recurseManually'] ?? true, $options['useRawListOptions'] ?? null, ); } } ================================================ FILE: src/Ftp/FtpConnectionProvider.php ================================================ createConnectionResource( $options->host(), $options->port(), $options->timeout(), $options->ssl() ); try { $this->authenticate($options, $connection); $this->enableUtf8Mode($options, $connection); $this->ignorePassiveAddress($options, $connection); $this->makeConnectionPassive($options, $connection); } catch (FtpConnectionException $exception) { @ftp_close($connection); throw $exception; } return $connection; } /** * @return resource */ private function createConnectionResource(string $host, int $port, int $timeout, bool $ssl) { error_clear_last(); $connection = $ssl ? @ftp_ssl_connect($host, $port, $timeout) : @ftp_connect($host, $port, $timeout); if ($connection === false) { throw UnableToConnectToFtpHost::forHost($host, $port, $ssl, error_get_last()['message'] ?? ''); } return $connection; } /** * @param resource $connection */ private function authenticate(FtpConnectionOptions $options, $connection): void { if ( ! @ftp_login($connection, $options->username(), $options->password())) { throw new UnableToAuthenticate(); } } /** * @param resource $connection */ private function enableUtf8Mode(FtpConnectionOptions $options, $connection): void { if ( ! $options->utf8()) { return; } $response = @ftp_raw($connection, "OPTS UTF8 ON"); if ( ! in_array(substr($response[0], 0, 3), ['200', '202'])) { throw new UnableToEnableUtf8Mode( 'Could not set UTF-8 mode for connection: ' . $options->host() . '::' . $options->port() ); } } /** * @param resource $connection */ private function ignorePassiveAddress(FtpConnectionOptions $options, $connection): void { $ignorePassiveAddress = $options->ignorePassiveAddress(); if ( ! is_bool($ignorePassiveAddress) || ! defined('FTP_USEPASVADDRESS')) { return; } if ( ! @ftp_set_option($connection, FTP_USEPASVADDRESS, ! $ignorePassiveAddress)) { throw UnableToSetFtpOption::whileSettingOption('FTP_USEPASVADDRESS'); } } /** * @param resource $connection */ private function makeConnectionPassive(FtpConnectionOptions $options, $connection): void { if ( ! @ftp_pasv($connection, $options->passive())) { throw new UnableToMakeConnectionPassive( 'Could not set passive mode for connection: ' . $options->host() . '::' . $options->port() ); } } } ================================================ FILE: src/Ftp/FtpConnectionProviderTest.php ================================================ retryOnException(UnableToConnectToFtpHost::class); } /** * @before */ public function setupConnectionProvider(): void { $this->connectionProvider = new FtpConnectionProvider(); } /** * @after */ public function resetFunctionMocks(): void { reset_function_mocks(); } /** * @test */ public function connecting_successfully(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'utf8' => true, 'passive' => true, 'ignorePassiveAddress' => true, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'pass', ]); $this->runScenario(function () use ($options) { $connection = $this->connectionProvider->createConnection($options); $this->assertTrue(ftp_close($connection)); }); } /** * @test */ public function not_being_able_to_enable_uft8_mode(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'utf8' => true, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'pass', ]); mock_function('ftp_raw', ['Error']); $this->expectException(UnableToEnableUtf8Mode::class); $this->runScenario(function () use ($options) { $this->connectionProvider->createConnection($options); }); } /** * @test */ public function uft8_mode_already_active_by_server(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'utf8' => true, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'pass', ]); mock_function('ftp_raw', ['202 UTF8 mode is always enabled. No need to send this command.']); $this->expectNotToPerformAssertions(); $this->runScenario(function () use ($options) { $this->connectionProvider->createConnection($options); }); } /** * @test */ public function not_being_able_to_ignore_the_passive_address(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'ignorePassiveAddress' => true, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'pass', ]); mock_function('ftp_set_option', false); $this->expectException(UnableToSetFtpOption::class); $this->runScenario(function () use ($options) { $this->connectionProvider->createConnection($options); }); } /** * @test */ public function not_being_able_to_make_the_connection_passive(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'utf8' => true, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'pass', ]); mock_function('ftp_pasv', false); $this->expectException(UnableToMakeConnectionPassive::class); $this->runScenario(function () use ($options) { $this->connectionProvider->createConnection($options); }); } /** * @test */ public function not_being_able_to_connect(): void { $this->dontRetryOnException(); $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 313131, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'pass', ]); $this->expectException(UnableToConnectToFtpHost::class); $this->connectionProvider->createConnection($options); } /** * @test */ public function not_being_able_to_connect_over_ssl(): void { $this->dontRetryOnException(); $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'ssl' => true, 'port' => 313131, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'pass', ]); $this->expectException(UnableToConnectToFtpHost::class); $this->connectionProvider->createConnection($options); } /** * @test */ public function not_being_able_to_authenticate(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'lolnope', ]); $this->expectException(UnableToAuthenticate::class); $this->retryOnException(UnableToConnectToFtpHost::class); $this->runScenario(function () use ($options) { $this->connectionProvider->createConnection($options); }); } } ================================================ FILE: src/Ftp/FtpdAdapterTest.php ================================================ 'localhost', 'port' => 2122, 'timestampsOnUnixListingsEnabled' => true, 'root' => '/', 'username' => 'foo', 'password' => 'pass', ]); static::$connectivityChecker = new ConnectivityCheckerThatCanFail(new NoopCommandConnectivityChecker()); return new FtpAdapter($options, null, static::$connectivityChecker); } } ================================================ FILE: src/Ftp/InvalidListResponseReceived.php ================================================ retryOnException(UnableToConnectToFtpHost::class); } /** * @test */ public function detecting_a_good_connection(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'pass', ]); $connection = (new FtpConnectionProvider())->createConnection($options); $connected = (new NoopCommandConnectivityChecker())->isConnected($connection); $this->assertTrue($connected); } /** * @test */ public function detecting_a_closed_connection(): void { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'root' => '/home/foo/upload', 'username' => 'foo', 'password' => 'pass', ]); $this->runScenario(function () use ($options) { $connection = (new FtpConnectionProvider())->createConnection($options); ftp_close($connection); $connected = (new NoopCommandConnectivityChecker())->isConnected($connection); $this->assertFalse($connected); }); } } ================================================ FILE: src/Ftp/README.md ================================================ ## Sub-split of Flysystem for FTP. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-ftp ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/ftp/). ================================================ FILE: src/Ftp/RawListFtpConnectivityChecker.php ================================================ retryOnException(UnableToConnectToFtpHost::class); $this->runScenario(function () { $options = FtpConnectionOptions::fromArray([ 'host' => 'localhost', 'port' => 2121, 'root' => '/home/foo/upload/', 'username' => 'foo', 'password' => 'pass', ]); $provider = new FtpConnectionProvider(); $connection = $provider->createConnection($options); $connectedChecker = new RawListFtpConnectivityChecker(); $this->assertTrue($connectedChecker->isConnected($connection)); @ftp_close($connection); $this->assertFalse($connectedChecker->isConnected($connection)); }); } } ================================================ FILE: src/Ftp/StubConnectionProvider.php ================================================ connection = $this->provider->createConnection($options); } } ================================================ FILE: src/Ftp/UnableToAuthenticate.php ================================================ 'md5Hash', 'crc32c' => 'crc32c', 'etag' => 'etag', ]; public function __construct( private Bucket $bucket, string $prefix = '', ?VisibilityHandler $visibilityHandler = null, private string $defaultVisibility = Visibility::PRIVATE, ?MimeTypeDetector $mimeTypeDetector = null, private bool $streamReads = false, ) { $this->prefixer = new PathPrefixer($prefix); $this->visibilityHandler = $visibilityHandler ?? new PortableVisibilityHandler(); $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); } public function publicUrl(string $path, Config $config): string { $location = $this->prefixer->prefixPath($path); return 'https://storage.googleapis.com/' . $this->bucket->name() . '/' . ltrim($location, '/'); } public function fileExists(string $path): bool { $prefixedPath = $this->prefixer->prefixPath($path); try { return $this->bucket->object($prefixedPath)->exists(); } catch (Throwable $exception) { throw UnableToCheckFileExistence::forLocation($path, $exception); } } public function directoryExists(string $path): bool { $prefixedPath = $this->prefixer->prefixPath($path); $options = [ 'delimiter' => '/', 'includeTrailingDelimiter' => true, ]; if (strlen($prefixedPath) > 0) { $options = ['prefix' => rtrim($prefixedPath, '/') . '/']; } try { $objects = $this->bucket->objects($options); } catch (Throwable $exception) { throw UnableToCheckDirectoryExistence::forLocation($path, $exception); } if (count($objects->prefixes()) > 0) { return true; } /** @var StorageObject $object */ foreach ($objects as $object) { return true; } return false; } public function write(string $path, string $contents, Config $config): void { $this->upload($path, $contents, $config); } public function writeStream(string $path, $contents, Config $config): void { $this->upload($path, $contents, $config); } /** * @param resource|string $contents */ private function upload(string $path, $contents, Config $config): void { $prefixedPath = $this->prefixer->prefixPath($path); $options = ['name' => $prefixedPath]; $visibility = $config->get(Config::OPTION_VISIBILITY, $this->defaultVisibility); $predefinedAcl = $this->visibilityHandler->visibilityToPredefinedAcl($visibility); if ($predefinedAcl !== PortableVisibilityHandler::NO_PREDEFINED_VISIBILITY) { $options['predefinedAcl'] = $predefinedAcl; } $metadata = $config->get('metadata', []); $shouldDetermineMimetype = $contents !== '' && ! array_key_exists('contentType', $metadata); if ($shouldDetermineMimetype && $mimeType = $this->mimeTypeDetector->detectMimeType($path, $contents)) { $metadata['contentType'] = $mimeType; } $options['metadata'] = $metadata; try { $this->bucket->upload($contents, $options); } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } public function read(string $path): string { $prefixedPath = $this->prefixer->prefixPath($path); try { return $this->bucket->object($prefixedPath)->downloadAsString(); } catch (Throwable $exception) { throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); } } public function readStream(string $path) { $prefixedPath = $this->prefixer->prefixPath($path); $options = []; if ($this->streamReads) { $options['restOptions']['stream'] = true; } try { $stream = $this->bucket->object($prefixedPath)->downloadAsStream($options)->detach(); } catch (Throwable $exception) { throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); } // @codeCoverageIgnoreStart if ( ! is_resource($stream)) { throw UnableToReadFile::fromLocation($path, 'Downloaded object does not contain a file resource.'); } // @codeCoverageIgnoreEnd return $stream; } public function delete(string $path): void { try { $prefixedPath = $this->prefixer->prefixPath($path); $this->bucket->object($prefixedPath)->delete(); } catch (NotFoundException $thisIsOk) { // this is ok } catch (Throwable $exception) { throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception); } } public function deleteDirectory(string $path): void { try { /** @var StorageAttributes[] $listing */ $listing = $this->listContents($path, true); foreach ($listing as $attributes) { $this->delete($attributes->path()); } if ($path !== '') { $this->delete(rtrim($path, '/') . '/'); } } catch (Throwable $exception) { throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception); } } public function createDirectory(string $path, Config $config): void { $prefixedPath = $this->prefixer->prefixDirectoryPath($path); if ($prefixedPath !== '') { $this->bucket->upload('', ['name' => $prefixedPath]); } } public function setVisibility(string $path, string $visibility): void { try { $prefixedPath = $this->prefixer->prefixPath($path); $object = $this->bucket->object($prefixedPath); $this->visibilityHandler->setVisibility($object, $visibility); } catch (Throwable $previous) { throw UnableToSetVisibility::atLocation($path, $previous->getMessage(), $previous); } } public function visibility(string $path): FileAttributes { try { $prefixedPath = $this->prefixer->prefixPath($path); $object = $this->bucket->object($prefixedPath); $visibility = $this->visibilityHandler->determineVisibility($object); return new FileAttributes($path, null, $visibility); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::visibility($path, $exception->getMessage(), $exception); } } public function mimeType(string $path): FileAttributes { return $this->fileAttributes($path, 'mimeType'); } public function lastModified(string $path): FileAttributes { return $this->fileAttributes($path, 'lastModified'); } public function fileSize(string $path): FileAttributes { return $this->fileAttributes($path, 'fileSize'); } private function fileAttributes(string $path, string $type): FileAttributes { $exception = null; $prefixedPath = $this->prefixer->prefixPath($path); try { $object = $this->bucket->object($prefixedPath); $fileAttributes = $this->storageObjectToStorageAttributes($object); } catch (Throwable $exception) { // passthrough } if ( ! isset($fileAttributes) || ! $fileAttributes instanceof FileAttributes || $fileAttributes[$type] === null) { throw UnableToRetrieveMetadata::{$type}($path, isset($exception) ? $exception->getMessage() : '', $exception); } return $fileAttributes; } public function storageObjectToStorageAttributes(StorageObject $object): StorageAttributes { $path = $this->prefixer->stripPrefix($object->name()); $info = $object->info(); $lastModified = strtotime($info['updated']); if (substr($path, -1, 1) === '/') { return new DirectoryAttributes(rtrim($path, '/'), null, $lastModified); } $fileSize = intval($info['size']); $mimeType = $info['contentType'] ?? null; return new FileAttributes($path, $fileSize, null, $lastModified, $mimeType, $info); } public function listContents(string $path, bool $deep): iterable { $prefixedPath = $this->prefixer->prefixPath($path); $prefixes = $options = []; if ($prefixedPath !== '') { $options = ['prefix' => sprintf('%s/', rtrim($prefixedPath, '/'))]; } if ($deep === false) { $options['delimiter'] = '/'; $options['includeTrailingDelimiter'] = true; } $objects = $this->bucket->objects($options); /** @var StorageObject $object */ foreach ($objects as $object) { $prefixes[$this->prefixer->stripDirectoryPrefix($object->name())] = true; yield $this->storageObjectToStorageAttributes($object); } foreach ($objects->prefixes() as $prefix) { $prefix = $this->prefixer->stripDirectoryPrefix($prefix); if (array_key_exists($prefix, $prefixes)) { continue; } $prefixes[$prefix] = true; yield new DirectoryAttributes($prefix); } } public function move(string $source, string $destination, Config $config): void { try { $this->copy($source, $destination, $config); $this->delete($source); } catch (Throwable $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } } public function copy(string $source, string $destination, Config $config): void { try { $visibility = $config->get(Config::OPTION_VISIBILITY); if ($visibility === null && $config->get(Config::OPTION_RETAIN_VISIBILITY, true)) { $visibility = $this->visibility($source)->visibility(); } $prefixedSource = $this->prefixer->prefixPath($source); $options = ['name' => $this->prefixer->prefixPath($destination)]; $predefinedAcl = $this->visibilityHandler->visibilityToPredefinedAcl( $visibility ?: PortableVisibilityHandler::NO_PREDEFINED_VISIBILITY ); if ($predefinedAcl !== PortableVisibilityHandler::NO_PREDEFINED_VISIBILITY) { $options['predefinedAcl'] = $predefinedAcl; } $this->bucket->object($prefixedSource)->copy($this->bucket, $options); } catch (Throwable $previous) { throw UnableToCopyFile::fromLocationTo($source, $destination, $previous); } } public function checksum(string $path, Config $config): string { $algo = $config->get('checksum_algo', 'md5'); $header = static::$algoToInfoMap[$algo] ?? null; if ($header === null) { throw new ChecksumAlgoIsNotSupported(); } $prefixedPath = $this->prefixer->prefixPath($path); try { $checksum = $this->bucket->object($prefixedPath)->info()[$header] ?? throw new LogicException("Header not present: $header"); } catch (Throwable $exception) { throw new UnableToProvideChecksum($exception->getMessage(), $path); } return bin2hex(base64_decode($checksum)); } public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string { $location = $this->prefixer->prefixPath($path); try { return $this->bucket->object($location)->signedUrl($expiresAt, $config->get('gcp_signing_options', [])); } catch (Throwable $exception) { throw UnableToGenerateTemporaryUrl::dueToError($path, $exception); } } } ================================================ FILE: src/GoogleCloudStorage/GoogleCloudStorageAdapterTest.php ================================================ prefixPath($path); } public function prefixDirectoryPath(string $path): string { return static::$prefixer->prefixDirectoryPath($path); } protected static function createFilesystemAdapter(): FilesystemAdapter { if ( ! file_exists(__DIR__ . '/../../google-cloud-service-account.json')) { self::markTestSkipped("No google service account found in project root."); } $clientOptions = [ 'projectId' => getenv('GOOGLE_CLOUD_PROJECT'), 'keyFilePath' => __DIR__ . '/../../google-cloud-service-account.json', ]; $storageClient = new StubStorageClient($clientOptions); /** @var StubRiggedBucket $bucket */ $bucket = $storageClient->bucket(self::bucketName()); static::$bucket = $bucket; return new GoogleCloudStorageAdapter( $bucket, static::$adapterPrefix, visibilityHandler: self::visibilityHandler(), ); } /** * @test */ public function writing_with_specific_metadata(): void { $adapter = $this->adapter(); $adapter->write('some/path.txt', 'contents', new Config(['metadata' => ['contentType' => 'text/plain+special']])); $mimeType = $adapter->mimeType('some/path.txt')->mimeType(); $this->assertEquals('text/plain+special', $mimeType); } /** * @test */ public function guessing_the_mime_type_when_writing(): void { $adapter = $this->adapter(); $adapter->write('some/config.txt', '', new Config()); $mimeType = $adapter->mimeType('some/config.txt')->mimeType(); $this->assertEquals('text/xml', $mimeType); } /** * @test */ public function fetching_visibility_of_non_existing_file(): void { $this->markTestSkipped(" Not relevant for this adapter since it's a missing ACL, which turns into a 404 which is the expected outcome of a private visibility. ¯\_(ツ)_/¯ "); } /** * @test */ public function fetching_unknown_mime_type_of_a_file(): void { $this->markTestSkipped("This adapter always returns a mime-type."); } /** * @test */ public function listing_a_toplevel_directory(): void { $this->clearStorage(); parent::listing_a_toplevel_directory(); } /** * @test */ public function failing_to_write_a_file(): void { $adapter = $this->adapter(); static::$bucket->failForUpload($this->prefixPath('something.txt')); $this->expectException(UnableToWriteFile::class); $adapter->write('something.txt', 'contents', new Config()); } /** * @test */ public function failing_to_delete_a_file(): void { $adapter = $this->adapter(); static::$bucket->failForObject($this->prefixPath('filename.txt')); $this->expectException(UnableToDeleteFile::class); $adapter->delete('filename.txt'); } /** * @test */ public function failing_to_delete_a_directory(): void { $adapter = $this->adapter(); $this->givenWeHaveAnExistingFile('dir/filename.txt'); static::$bucket->failForObject($this->prefixPath('dir/filename.txt')); $this->expectException(UnableToDeleteDirectory::class); $adapter->deleteDirectory('dir'); } /** * @test */ public function failing_to_retrieve_visibility(): void { $adapter = $this->adapter(); static::$bucket->failForObject($this->prefixPath('filename.txt')); $this->expectException(UnableToRetrieveMetadata::class); $adapter->visibility('filename.txt'); } } ================================================ FILE: src/GoogleCloudStorage/GoogleCloudStorageAdapterWithoutAclTest.php ================================================ acl()->delete($this->entity); } elseif ($visibility === Visibility::PUBLIC) { $object->acl()->update($this->entity, Acl::ROLE_READER); } } public function determineVisibility(StorageObject $object): string { try { $acl = $object->acl()->get(['entity' => 'allUsers']); } catch (NotFoundException $exception) { return Visibility::PRIVATE; } return $acl['role'] === Acl::ROLE_READER ? Visibility::PUBLIC : Visibility::PRIVATE; } public function visibilityToPredefinedAcl(string $visibility): string { switch ($visibility) { case Visibility::PUBLIC: return $this->predefinedPublicAcl; case self::NO_PREDEFINED_VISIBILITY: return self::NO_PREDEFINED_VISIBILITY; default: return $this->predefinedPrivateAcl; } } } ================================================ FILE: src/GoogleCloudStorage/README.md ================================================ ## Sub-split of Flysystem for Google Cloud Storage (GCS). > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-google-cloud-storage ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/google-cloud-storage/). ================================================ FILE: src/GoogleCloudStorage/StubRiggedBucket.php ================================================ setupTrigger('object', $name, $throwable); } public function failForUpload(string $name, ?Throwable $throwable = null): void { $this->setupTrigger('upload', $name, $throwable); } public function object($name, array $options = []) { $this->pushTrigger('object', $name); return parent::object($name, $options); } public function upload($data, array $options = []) { $this->pushTrigger('upload', $options['name'] ?? 'unknown-object-name'); return parent::upload($data, $options); } private function setupTrigger(string $method, string $name, ?Throwable $throwable): void { $this->triggers[$method][$name] = $throwable ?? new LogicException('unknown error'); } private function pushTrigger(string $method, string $name): void { $trigger = $this->triggers[$method][$name] ?? null; if ($trigger instanceof Throwable) { unset($this->triggers[$method][$name]); throw $trigger; } } } ================================================ FILE: src/GoogleCloudStorage/StubStorageClient.php ================================================ riggedBucket) { $this->riggedBucket = new StubRiggedBucket($this->connection, $name, [ 'requesterProjectId' => $this->projectId, ]); } return $isKnownBucket ? $this->riggedBucket : parent::bucket($name, $userProject); } } ================================================ FILE: src/GoogleCloudStorage/UniformBucketLevelAccessVisibility.php ================================================ ['root' => 'array', 'document' => 'array', 'array' => 'array'], 'codec' => null, ]; private Bucket $bucket; private PathPrefixer $prefixer; private MimeTypeDetector $mimeTypeDetector; public function __construct( Bucket $bucket, string $prefix = '', ?MimeTypeDetector $mimeTypeDetector = null, ) { $this->bucket = $bucket; $this->prefixer = new PathPrefixer($prefix); $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); } public function fileExists(string $path): bool { $file = $this->findFile($path); return $file !== null; } public function directoryExists(string $path): bool { // A directory exists if at least one file exists with a path starting with the directory name $files = $this->listContents($path, true); foreach ($files as $file) { return true; } return false; } public function write(string $path, string $contents, Config $config): void { if (str_ends_with($path, '/')) { throw UnableToWriteFile::atLocation($path, 'file path cannot end with a slash'); } $filename = $this->prefixer->prefixPath($path); $options = [ 'metadata' => $config->get('metadata', []), ]; if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { $options['metadata'][self::METADATA_VISIBILITY] = $visibility; } if (($mimeType = $config->get('mimetype')) || ($mimeType = $this->mimeTypeDetector->detectMimeType($path, $contents))) { $options['metadata'][self::METADATA_MIMETYPE] = $mimeType; } try { $stream = $this->bucket->openUploadStream($filename, $options); fwrite($stream, $contents); fclose($stream); } catch (Exception $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } public function writeStream(string $path, $contents, Config $config): void { if (str_ends_with($path, '/')) { throw UnableToWriteFile::atLocation($path, 'file path cannot end with a slash'); } $filename = $this->prefixer->prefixPath($path); $options = []; if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { $options['metadata'][self::METADATA_VISIBILITY] = $visibility; } if (($mimetype = $config->get('mimetype')) || ($mimetype = $this->mimeTypeDetector->detectMimeTypeFromPath($path))) { $options['metadata'][self::METADATA_MIMETYPE] = $mimetype; } try { $this->bucket->uploadFromStream($filename, $contents, $options); } catch (Exception $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } public function read(string $path): string { $stream = $this->readStream($path); try { return stream_get_contents($stream); } finally { fclose($stream); } } public function readStream(string $path) { if (str_ends_with($path, '/')) { throw UnableToReadFile::fromLocation($path, 'file path cannot end with a slash'); } try { $filename = $this->prefixer->prefixPath($path); return $this->bucket->openDownloadStreamByName($filename); } catch (FileNotFoundException $exception) { throw UnableToReadFile::fromLocation($path, 'file does not exist', $exception); } catch (Exception $exception) { throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); } } /** * Delete all revisions of the file name, starting with the oldest, * no-op if the file does not exist. * * @throws UnableToDeleteFile */ public function delete(string $path): void { if (str_ends_with($path, '/')) { throw UnableToDeleteFile::atLocation($path, 'file path cannot end with a slash'); } $filename = $this->prefixer->prefixPath($path); try { $this->findAndDelete(['filename' => $filename]); } catch (Exception $exception) { throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception); } } public function deleteDirectory(string $path): void { $prefixedPath = $this->prefixer->prefixDirectoryPath($path); try { $this->findAndDelete(['filename' => new Regex('^' . preg_quote($prefixedPath))]); } catch (Exception $exception) { throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception); } } public function createDirectory(string $path, Config $config): void { $dirname = $this->prefixer->prefixDirectoryPath($path); $options = [ 'metadata' => $config->get('metadata', []) + [self::METADATA_DIRECTORY => true], ]; if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { $options['metadata'][self::METADATA_VISIBILITY] = $visibility; } try { $stream = $this->bucket->openUploadStream($dirname, $options); fwrite($stream, ''); fclose($stream); } catch (Exception $exception) { throw UnableToCreateDirectory::atLocation($path, $exception->getMessage(), $exception); } } public function setVisibility(string $path, string $visibility): void { $file = $this->findFile($path); if ($file === null) { throw UnableToSetVisibility::atLocation($path, 'file does not exist'); } try { $this->bucket->getFilesCollection()->updateOne( ['_id' => $file['_id']], ['$set' => ['metadata.' . self::METADATA_VISIBILITY => $visibility]], ); } catch (Exception $exception) { throw UnableToSetVisibility::atLocation($path, $exception->getMessage(), $exception); } } public function visibility(string $path): FileAttributes { $file = $this->findFile($path); if ($file === null) { throw UnableToRetrieveMetadata::mimeType($path, 'file does not exist'); } return $this->mapFileAttributes($file); } public function fileSize(string $path): FileAttributes { if (str_ends_with($path, '/')) { throw UnableToRetrieveMetadata::fileSize($path, 'file path cannot end with a slash'); } $file = $this->findFile($path); if ($file === null) { throw UnableToRetrieveMetadata::fileSize($path, 'file does not exist'); } return $this->mapFileAttributes($file); } public function mimeType(string $path): FileAttributes { if (str_ends_with($path, '/')) { throw UnableToRetrieveMetadata::mimeType($path, 'file path cannot end with a slash'); } $file = $this->findFile($path); if ($file === null) { throw UnableToRetrieveMetadata::mimeType($path, 'file does not exist'); } $attributes = $this->mapFileAttributes($file); if ($attributes->mimeType() === null) { throw UnableToRetrieveMetadata::mimeType($path, 'unknown'); } return $attributes; } public function lastModified(string $path): FileAttributes { if (str_ends_with($path, '/')) { throw UnableToRetrieveMetadata::lastModified($path, 'file path cannot end with a slash'); } $file = $this->findFile($path); if ($file === null) { throw UnableToRetrieveMetadata::lastModified($path, 'file does not exist'); } return $this->mapFileAttributes($file); } public function listContents(string $path, bool $deep): iterable { $path = $this->prefixer->prefixDirectoryPath($path); $pathdeep = 0; // Get the last revision of each file, using the index on the files collection $pipeline = [['$sort' => ['filename' => 1, 'uploadDate' => 1]]]; if ($path !== '') { $pathdeep = substr_count($path, '/'); // Exclude files that do not start with the expected path $pipeline[] = ['$match' => ['filename' => new Regex('^' . preg_quote($path))]]; } if ($deep === false) { $pipeline[] = ['$addFields' => ['splitpath' => ['$split' => ['$filename', '/']]]]; $pipeline[] = ['$group' => [ // The same name could be used as a filename and as part of the path of other files '_id' => [ 'basename' => ['$arrayElemAt' => ['$splitpath', $pathdeep]], 'isDir' => ['$ne' => [['$size' => '$splitpath'], $pathdeep + 1]], ], // Get the metadata of the last revision of each file 'file' => ['$last' => '$$ROOT'], // The "lastModified" date is the date of the last uploaded file in the directory 'uploadDate' => ['$max' => '$uploadDate'], ]]; $files = $this->bucket->getFilesCollection()->aggregate($pipeline, self::TYPEMAP_ARRAY); foreach ($files as $file) { if ($file['_id']['isDir']) { yield new DirectoryAttributes( $this->prefixer->stripDirectoryPrefix($path . $file['_id']['basename']), null, $file['uploadDate']->toDateTime()->getTimestamp(), ); } else { yield $this->mapFileAttributes($file['file']); } } } else { // Get the metadata of the last revision of each file $pipeline[] = ['$group' => [ '_id' => '$filename', 'file' => ['$first' => '$$ROOT'], ]]; $files = $this->bucket->getFilesCollection()->aggregate($pipeline, self::TYPEMAP_ARRAY); foreach ($files as $file) { $file = $file['file']; if (str_ends_with($file['filename'], '/')) { // Empty files with a trailing slash are markers for directories, only for Flysystem yield new DirectoryAttributes( $this->prefixer->stripDirectoryPrefix($file['filename']), $file['metadata'][self::METADATA_VISIBILITY] ?? null, $file['uploadDate']->toDateTime()->getTimestamp(), $file, ); } else { yield $this->mapFileAttributes($file); } } } } public function move(string $source, string $destination, Config $config): void { if ($source === $destination) { return; } if ($this->fileExists($destination)) { $this->delete($destination); } try { $result = $this->bucket->getFilesCollection()->updateMany( ['filename' => $this->prefixer->prefixPath($source)], ['$set' => ['filename' => $this->prefixer->prefixPath($destination)]], ); if ($result->getModifiedCount() === 0) { throw UnableToMoveFile::because('file does not exist', $source, $destination); } } catch (Exception $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } } public function copy(string $source, string $destination, Config $config): void { $file = $this->findFile($source); if ($file === null) { throw UnableToCopyFile::fromLocationTo( $source, $destination, ); } $options = []; if (($visibility = $config->get(Config::OPTION_VISIBILITY)) || $visibility = $file['metadata'][self::METADATA_VISIBILITY] ?? null) { $options['metadata'][self::METADATA_VISIBILITY] = $visibility; } if (($mimetype = $config->get('mimetype')) || $mimetype = $file['metadata'][self::METADATA_MIMETYPE] ?? null) { $options['metadata'][self::METADATA_MIMETYPE] = $mimetype; } try { $stream = $this->bucket->openDownloadStream($file['_id']); $this->bucket->uploadFromStream($this->prefixer->prefixPath($destination), $stream, $options); } catch (Exception $exception) { throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } /** * Get the last revision of the file name. * * @return GridFile|null */ private function findFile(string $path): ?array { $filename = $this->prefixer->prefixPath($path); $files = $this->bucket->find( ['filename' => $filename], ['sort' => ['uploadDate' => -1], 'limit' => 1] + self::TYPEMAP_ARRAY, ); return $files->toArray()[0] ?? null; } /** * @param GridFile $file */ private function mapFileAttributes(array $file): FileAttributes { return new FileAttributes( $this->prefixer->stripPrefix($file['filename']), $file['length'], $file['metadata'][self::METADATA_VISIBILITY] ?? null, $file['uploadDate']->toDateTime()->getTimestamp(), $file['metadata'][self::METADATA_MIMETYPE] ?? null, $file, ); } /** * @throws Exception */ private function findAndDelete(array $filter): void { $files = $this->bucket->find( $filter, ['sort' => ['uploadDate' => 1], 'projection' => ['_id' => 1]] + self::TYPEMAP_ARRAY, ); foreach ($files as $file) { try { $this->bucket->delete($file['_id']); } catch (FileNotFoundException) { // Ignore error due to race condition } } } } ================================================ FILE: src/GridFS/GridFSAdapterTest.php ================================================ drop(); parent::tearDownAfterClass(); } /** * @test */ public function fetching_contains_extra_metadata(): void { $adapter = $this->adapter(); $this->runScenario(function () use ($adapter) { $this->givenWeHaveAnExistingFile('file.txt'); $fileAttributes = $adapter->lastModified('file.txt'); $extra = $fileAttributes->extraMetadata(); $this->assertArrayHasKey('_id', $extra); $this->assertArrayHasKey('filename', $extra); }); } /** * @test */ public function fetching_last_modified_of_a_directory(): void { $this->expectException(UnableToRetrieveMetadata::class); $adapter = $this->adapter(); $this->runScenario(function () use ($adapter) { $adapter->createDirectory('path', new Config()); $adapter->lastModified('path/'); }); } /** * @test */ public function fetching_mime_type_of_a_directory(): void { $this->expectException(UnableToRetrieveMetadata::class); $adapter = $this->adapter(); $this->runScenario(function () use ($adapter) { $adapter->createDirectory('path', new Config()); $adapter->mimeType('path/'); }); } /** * @test */ public function reading_a_file_with_trailing_slash(): void { $this->expectException(UnableToReadFile::class); $this->adapter()->read('foo/'); } /** * @test */ public function reading_a_file_stream_with_trailing_slash(): void { $this->expectException(UnableToReadFile::class); $this->adapter()->readStream('foo/'); } /** * @test */ public function writing_a_file_with_trailing_slash(): void { $this->expectException(UnableToWriteFile::class); $this->adapter()->write('foo/', 'contents', new Config()); } /** * @test */ public function writing_a_file_stream_with_trailing_slash(): void { $this->expectException(UnableToWriteFile::class); $writeStream = stream_with_contents('contents'); $this->adapter()->writeStream('foo/', $writeStream, new Config()); } /** * @test */ public function writing_a_file_with_a_invalid_stream(): void { $this->expectException(UnableToWriteFile::class); // @phpstan-ignore argument.type $this->adapter()->writeStream('file.txt', 'foo', new Config()); } /** * @test */ public function delete_a_file_with_trailing_slash(): void { $this->expectException(UnableToDeleteFile::class); $this->adapter()->delete('foo/'); } /** * @test */ public function reading_last_revision(): void { $this->runScenario( function () { $this->givenWeHaveAnExistingFile('file.txt', 'version 1'); usleep(1000); $this->givenWeHaveAnExistingFile('file.txt', 'version 2'); $this->assertSame('version 2', $this->adapter()->read('file.txt')); } ); } /** * @testWith [false] * [true] * * @test */ public function listing_contents_last_revision(bool $deep): void { $this->runScenario( function () use ($deep) { $this->givenWeHaveAnExistingFile('file.txt', 'version 1'); usleep(1000); $this->givenWeHaveAnExistingFile('file.txt', 'version 2'); $files = $this->adapter()->listContents('', $deep); $files = iterator_to_array($files); $this->assertCount(1, $files); $file = $files[0]; $this->assertInstanceOf(FileAttributes::class, $file); $this->assertSame('file.txt', $file->path()); } ); } /** * @test */ public function listing_contents_directory_with_multiple_files(): void { $this->runScenario( function () { $this->givenWeHaveAnExistingFile('some/file-1.txt'); $this->givenWeHaveAnExistingFile('some/file-2.txt'); $this->givenWeHaveAnExistingFile('some/other/file-1.txt'); $files = $this->adapter()->listContents('', false); $files = iterator_to_array($files); $this->assertCount(1, $files); $file = $files[0]; $this->assertInstanceOf(DirectoryAttributes::class, $file); $this->assertSame('some', $file->path()); } ); } /** * @test */ public function delete_all_revisions(): void { $this->runScenario( function () { $this->givenWeHaveAnExistingFile('file.txt', 'version 1'); usleep(1000); $this->givenWeHaveAnExistingFile('file.txt', 'version 2'); usleep(1000); $this->givenWeHaveAnExistingFile('file.txt', 'version 3'); $this->adapter()->delete('file.txt'); $this->assertFalse($this->adapter()->fileExists('file.txt'), 'File does not exist'); } ); } /** * @test */ public function move_all_revisions(): void { $this->runScenario( function () { $this->givenWeHaveAnExistingFile('file.txt', 'version 1'); usleep(1000); $this->givenWeHaveAnExistingFile('file.txt', 'version 2'); usleep(1000); $this->givenWeHaveAnExistingFile('file.txt', 'version 3'); $this->adapter()->move('file.txt', 'destination.txt', new Config()); $this->assertFalse($this->adapter()->fileExists('file.txt')); $this->assertSame($this->adapter()->read('destination.txt'), 'version 3'); } ); } protected function tearDown(): void { self::getDatabase()->selectGridFSBucket()->drop(); parent::tearDown(); } protected static function createFilesystemAdapter(): FilesystemAdapter { $bucket = self::getDatabase()->selectGridFSBucket(); $prefix = getenv('FLYSYSTEM_MONGODB_PREFIX') ?: self::$adapterPrefix; return new GridFSAdapter($bucket, $prefix); } private static function getDatabase(): Database { $uri = getenv('MONGODB_URI') ?: 'mongodb://127.0.0.1:27017/'; $client = new Client($uri); return $client->selectDatabase(getenv('MONGODB_DATABASE') ?: 'flysystem_tests'); } } ================================================ FILE: src/GridFS/LICENSE ================================================ Copyright (c) 2024-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/GridFS/README.md ================================================ ## Sub-split for Flysystem's MongoDB GridFS Adapter > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-gridfs ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/gridfs/). ================================================ FILE: src/GridFS/composer.json ================================================ { "name": "league/flysystem-gridfs", "autoload": { "psr-4": { "League\\Flysystem\\GridFS\\": "" } }, "require": { "php": "^8.0.2", "ext-mongodb": "^1.3|^2", "league/flysystem": "^3.10.0", "mongodb/mongodb": "^1.2|^2" }, "license": "MIT", "authors": [ { "name": "Frank de Jonge", "email": "info@frankdejonge.nl" }, { "name": "MongoDB PHP", "email": "driver-php@mongodb.com" } ] } ================================================ FILE: src/InMemory/.gitattributes ================================================ * text=auto .github export-ignore .gitattributes export-ignore .gitignore export-ignore **/*Test.php export-ignore README.md export-ignore ================================================ FILE: src/InMemory/.github/workflows/close-subsplit-prs.yaml ================================================ --- name: Close sub-split PRs on: push: branches: - 2.x - 3.x pull_request: branches: - 2.x - 3.x schedule: - cron: '30 7 * * *' jobs: close_subsplit_prs: runs-on: ubuntu-latest name: Close sub-split PRs steps: - uses: frankdejonge/action-close-subsplit-pr@0.1.0 with: close_pr: 'yes' target_branch_match: '^(?!master).+$' message: | Hi :wave:, Thank you for contributing to Flysystem. Unfortunately, you've sent a PR to a read-only sub-split repository. All pull requests should be directed towards: https://github.com/thephpleague/flysystem ================================================ FILE: src/InMemory/InMemoryFile.php ================================================ contents = $contents; $this->lastModified = $timestamp ?? time(); } public function lastModified(): int { return $this->lastModified; } public function withLastModified(int $lastModified): self { $clone = clone $this; $clone->lastModified = $lastModified; return $clone; } public function read(): string { return $this->contents; } /** * @return resource */ public function readStream() { /** @var resource $stream */ $stream = fopen('php://temp', 'w+b'); fwrite($stream, $this->contents); rewind($stream); return $stream; } public function fileSize(): int { return strlen($this->contents); } public function mimeType(): string { return (string) (new finfo(FILEINFO_MIME_TYPE))->buffer($this->contents); } public function setVisibility(string $visibility): void { $this->visibility = $visibility; } public function visibility(): ?string { return $this->visibility; } } ================================================ FILE: src/InMemory/InMemoryFilesystemAdapter.php ================================================ mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); } public function fileExists(string $path): bool { return array_key_exists($this->preparePath($path), $this->files); } public function write(string $path, string $contents, Config $config): void { $path = $this->preparePath($path); $file = $this->files[$path] = $this->files[$path] ?? new InMemoryFile(); $file->updateContents($contents, $config->get('timestamp')); $visibility = $config->get(Config::OPTION_VISIBILITY, $this->defaultVisibility); $file->setVisibility($visibility); } public function writeStream(string $path, $contents, Config $config): void { $this->write($path, (string) stream_get_contents($contents), $config); } public function read(string $path): string { $path = $this->preparePath($path); if (array_key_exists($path, $this->files) === false) { throw UnableToReadFile::fromLocation($path, 'file does not exist'); } return $this->files[$path]->read(); } public function readStream(string $path) { $path = $this->preparePath($path); if (array_key_exists($path, $this->files) === false) { throw UnableToReadFile::fromLocation($path, 'file does not exist'); } return $this->files[$path]->readStream(); } public function delete(string $path): void { unset($this->files[$this->preparePath($path)]); } public function deleteDirectory(string $path): void { $path = $this->preparePath($path); $path = rtrim($path, '/') . '/'; foreach (array_keys($this->files) as $filePath) { if (str_starts_with($filePath, $path)) { unset($this->files[$filePath]); } } } public function createDirectory(string $path, Config $config): void { $filePath = rtrim($path, '/') . '/' . self::DUMMY_FILE_FOR_FORCED_LISTING_IN_FLYSYSTEM_TEST; $this->write($filePath, '', $config); } public function directoryExists(string $path): bool { $path = $this->preparePath($path); $path = rtrim($path, '/') . '/'; foreach (array_keys($this->files) as $filePath) { if (str_starts_with($filePath, $path)) { return true; } } return false; } public function setVisibility(string $path, string $visibility): void { $path = $this->preparePath($path); if (array_key_exists($path, $this->files) === false) { throw UnableToSetVisibility::atLocation($path, 'file does not exist'); } $this->files[$path]->setVisibility($visibility); } public function visibility(string $path): FileAttributes { $path = $this->preparePath($path); if (array_key_exists($path, $this->files) === false) { throw UnableToRetrieveMetadata::visibility($path, 'file does not exist'); } return new FileAttributes($path, null, $this->files[$path]->visibility()); } public function mimeType(string $path): FileAttributes { $preparedPath = $this->preparePath($path); if (array_key_exists($preparedPath, $this->files) === false) { throw UnableToRetrieveMetadata::mimeType($path, 'file does not exist'); } $mimeType = $this->mimeTypeDetector->detectMimeType($path, $this->files[$preparedPath]->read()); if ($mimeType === null) { throw UnableToRetrieveMetadata::mimeType($path); } return new FileAttributes($preparedPath, null, null, null, $mimeType); } public function lastModified(string $path): FileAttributes { $path = $this->preparePath($path); if (array_key_exists($path, $this->files) === false) { throw UnableToRetrieveMetadata::lastModified($path, 'file does not exist'); } return new FileAttributes($path, null, null, $this->files[$path]->lastModified()); } public function fileSize(string $path): FileAttributes { $path = $this->preparePath($path); if (array_key_exists($path, $this->files) === false) { throw UnableToRetrieveMetadata::fileSize($path, 'file does not exist'); } return new FileAttributes($path, $this->files[$path]->fileSize()); } public function listContents(string $path, bool $deep): iterable { $prefix = rtrim($this->preparePath($path), '/') . '/'; $prefixLength = strlen($prefix); $listedDirectories = []; foreach ($this->files as $filePath => $file) { if (str_starts_with($filePath, $prefix)) { $subPath = substr($filePath, $prefixLength); $dirname = dirname($subPath); if ($dirname !== '.') { $parts = explode('/', $dirname); $dirPath = ''; foreach ($parts as $index => $part) { if ($deep === false && $index >= 1) { break; } $dirPath .= $part . '/'; if ( ! in_array($dirPath, $listedDirectories, true)) { $listedDirectories[] = $dirPath; yield new DirectoryAttributes(trim($prefix . $dirPath, '/')); } } } $dummyFilename = self::DUMMY_FILE_FOR_FORCED_LISTING_IN_FLYSYSTEM_TEST; if (str_ends_with($filePath, $dummyFilename)) { continue; } if ($deep === true || ! str_contains($subPath, '/')) { yield new FileAttributes(ltrim($filePath, '/'), $file->fileSize(), $file->visibility(), $file->lastModified(), $file->mimeType()); } } } } public function move(string $source, string $destination, Config $config): void { $sourcePath = $this->preparePath($source); $destinationPath = $this->preparePath($destination); if ( ! $this->fileExists($source)) { throw UnableToMoveFile::fromLocationTo($source, $destination); } if ($sourcePath !== $destinationPath) { $this->files[$destinationPath] = $this->files[$sourcePath]; unset($this->files[$sourcePath]); } if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { $this->setVisibility($destination, $visibility); } } public function copy(string $source, string $destination, Config $config): void { $source = $this->preparePath($source); $destination = $this->preparePath($destination); if ( ! $this->fileExists($source)) { throw UnableToCopyFile::fromLocationTo($source, $destination); } $lastModified = $config->get('timestamp', time()); $this->files[$destination] = $this->files[$source]->withLastModified($lastModified); if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { $this->setVisibility($destination, $visibility); } } private function preparePath(string $path): string { return '/' . ltrim($path, '/'); } public function deleteEverything(): void { $this->files = []; } } ================================================ FILE: src/InMemory/InMemoryFilesystemAdapterTest.php ================================================ adapter(); $filesystemAdapter->deleteEverything(); } /** * @test */ public function getting_mimetype_on_a_non_existing_file(): void { $this->expectException(UnableToRetrieveMetadata::class); $this->adapter()->mimeType('path.txt'); } /** * @test */ public function getting_last_modified_on_a_non_existing_file(): void { $this->expectException(UnableToRetrieveMetadata::class); $this->adapter()->lastModified('path.txt'); } /** * @test */ public function getting_file_size_on_a_non_existing_file(): void { $this->expectException(UnableToRetrieveMetadata::class); $this->adapter()->fileSize('path.txt'); } /** * @test */ public function deleting_a_file(): void { $this->adapter()->write('path.txt', 'contents', new Config()); $this->assertTrue($this->adapter()->fileExists('path.txt')); $this->adapter()->delete('path.txt'); $this->assertFalse($this->adapter()->fileExists('path.txt')); } /** * @test */ public function deleting_a_directory(): void { $adapter = $this->adapter(); $adapter->write('a/path.txt', 'contents', new Config()); $adapter->write('a/b/path.txt', 'contents', new Config()); $adapter->write('a/b/c/path.txt', 'contents', new Config()); $this->assertTrue($adapter->fileExists('a/b/path.txt')); $this->assertTrue($adapter->fileExists('a/b/c/path.txt')); $adapter->deleteDirectory('a/b'); $this->assertTrue($adapter->fileExists('a/path.txt')); $this->assertFalse($adapter->fileExists('a/b/path.txt')); $this->assertFalse($adapter->fileExists('a/b/c/path.txt')); } /** * @test */ public function creating_a_directory_does_nothing(): void { $this->adapter()->createDirectory('something', new Config()); $this->assertTrue(true); } /** * @test */ public function writing_with_a_stream_and_reading_a_file(): void { $handle = stream_with_contents('contents'); $this->adapter()->writeStream(self::PATH, $handle, new Config()); $contents = $this->adapter()->read(self::PATH); $this->assertEquals('contents', $contents); } /** * @test */ public function reading_a_stream(): void { $this->adapter()->write(self::PATH, 'contents', new Config()); $contents = $this->adapter()->readStream(self::PATH); $this->assertEquals('contents', stream_get_contents($contents)); fclose($contents); } /** * @test */ public function reading_a_non_existing_file(): void { $this->expectException(UnableToReadFile::class); $this->adapter()->read('path.txt'); } /** * @test */ public function stream_reading_a_non_existing_file(): void { $this->expectException(UnableToReadFile::class); $this->adapter()->readStream('path.txt'); } /** * @test */ public function listing_all_files(): void { $adapter = $this->adapter(); $adapter->write('path.txt', 'contents', new Config()); $adapter->write('a/path.txt', 'contents', new Config()); $adapter->write('a/b/path.txt', 'contents', new Config()); /** @var StorageAttributes[] $listing */ $listing = iterator_to_array($adapter->listContents('/', true)); $this->assertCount(5, $listing); $expected = [ 'path.txt' => StorageAttributes::TYPE_FILE, 'a/path.txt' => StorageAttributes::TYPE_FILE, 'a/b/path.txt' => StorageAttributes::TYPE_FILE, 'a' => StorageAttributes::TYPE_DIRECTORY, 'a/b' => StorageAttributes::TYPE_DIRECTORY, ]; foreach ($listing as $item) { $this->assertArrayHasKey($item->path(), $expected); $this->assertEquals($item->type(), $expected[$item->path()]); } } /** * @test */ public function listing_non_recursive(): void { $adapter = $this->adapter(); $adapter->write('path.txt', 'contents', new Config()); $adapter->write('a/path.txt', 'contents', new Config()); $adapter->write('a/b/path.txt', 'contents', new Config()); $listing = iterator_to_array($adapter->listContents('/', false)); $this->assertCount(2, $listing); } /** * @test */ public function moving_a_file_successfully(): void { $adapter = $this->adapter(); $adapter->write('path.txt', 'contents', new Config()); $adapter->move('path.txt', 'new-path.txt', new Config()); $this->assertFalse($adapter->fileExists('path.txt')); $this->assertTrue($adapter->fileExists('new-path.txt')); } /** * @test */ public function trying_to_move_a_non_existing_file(): void { $this->expectException(UnableToMoveFile::class); $this->adapter()->move('path.txt', 'new-path.txt', new Config()); } /** * @test */ public function copying_a_file_successfully(): void { $adapter = $this->adapter(); $adapter->write('path.txt', 'contents', new Config()); $adapter->copy('path.txt', 'new-path.txt', new Config()); $this->assertTrue($adapter->fileExists('path.txt')); $this->assertTrue($adapter->fileExists('new-path.txt')); } /** * @test */ public function trying_to_copy_a_non_existing_file(): void { $this->expectException(UnableToCopyFile::class); $this->adapter()->copy('path.txt', 'new-path.txt', new Config()); } /** * @test */ public function not_listing_directory_placeholders(): void { $adapter = $this->adapter(); $adapter->createDirectory('directory', new Config()); $contents = iterator_to_array($adapter->listContents('', true)); $this->assertCount(1, $contents); } /** * @test */ public function checking_for_metadata(): void { mock_function('time', 1234); $adapter = $this->adapter(); $adapter->write( self::PATH, (string) file_get_contents(__DIR__ . '/../AdapterTestUtilities/test_files/flysystem.svg'), new Config() ); $this->assertTrue($adapter->fileExists(self::PATH)); $this->assertEquals(754, $adapter->fileSize(self::PATH)->fileSize()); $this->assertEquals(1234, $adapter->lastModified(self::PATH)->lastModified()); $this->assertStringStartsWith('image/svg+xml', $adapter->mimeType(self::PATH)->mimeType()); } /** * @test */ public function fetching_unknown_mime_type_of_a_file(): void { $this->useAdapter(new InMemoryFilesystemAdapter(Visibility::PUBLIC, new ExtensionMimeTypeDetector(new EmptyExtensionToMimeTypeMap()))); parent::fetching_unknown_mime_type_of_a_file(); } /** * @test */ public function using_custom_timestamp(): void { $adapter = $this->adapter(); $now = 100; $adapter->write('file.txt', 'contents', new Config(['timestamp' => $now])); $this->assertEquals($now, $adapter->lastModified('file.txt')->lastModified()); $earlier = 50; $adapter->copy('file.txt', 'new_file.txt', new Config(['timestamp' => $earlier])); $this->assertEquals($earlier, $adapter->lastModified('new_file.txt')->lastModified()); } protected static function createFilesystemAdapter(): FilesystemAdapter { return new InMemoryFilesystemAdapter(); } } ================================================ FILE: src/InMemory/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/InMemory/README.md ================================================ ## Sub-split of Flysystem for in-memory file storage. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-memory ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/in-memory/). ================================================ FILE: src/InMemory/StaticInMemoryAdapterRegistry.php ================================================ */ private static array $filesystems = []; public static function get(string $name = 'default'): InMemoryFilesystemAdapter { return static::$filesystems[$name] ??= new InMemoryFilesystemAdapter(); } public static function deleteAllFilesystems(): void { self::$filesystems = []; } } ================================================ FILE: src/InMemory/StaticInMemoryAdapterRegistryTest.php ================================================ write('foo.txt', 'foo', new Config()); $second->write('bar.txt', 'bar', new Config()); $this->assertTrue($first->fileExists('foo.txt')); $this->assertFalse($first->fileExists('bar.txt')); $this->assertTrue($second->fileExists('bar.txt')); $this->assertFalse($second->fileExists('foo.txt')); } /** * @test */ public function files_persist_between_instances(): void { $first = StaticInMemoryAdapterRegistry::get(); $second = StaticInMemoryAdapterRegistry::get('second'); $first->write('foo.txt', 'foo', new Config()); $second->write('bar.txt', 'bar', new Config()); $this->assertTrue($first->fileExists('foo.txt')); $this->assertTrue($second->fileExists('bar.txt')); $first = StaticInMemoryAdapterRegistry::get(); $second = StaticInMemoryAdapterRegistry::get('second'); $this->assertTrue($first->fileExists('foo.txt')); $this->assertTrue($second->fileExists('bar.txt')); } protected function tearDown(): void { StaticInMemoryAdapterRegistry::deleteAllFilesystems(); } protected static function createFilesystemAdapter(): FilesystemAdapter { return StaticInMemoryAdapterRegistry::get(); } } ================================================ FILE: src/InMemory/composer.json ================================================ { "name": "league/flysystem-memory", "description": "In-memory filesystem adapter for Flysystem.", "keywords": ["flysystem", "filesystem", "memory", "file", "files"], "type": "library", "minimum-stability": "dev", "prefer-stable": true, "autoload": { "psr-4": { "League\\Flysystem\\InMemory\\": "" } }, "require": { "php": "^8.0.2", "ext-fileinfo": "*", "league/flysystem": "^3.0.0" }, "license": "MIT", "authors": [ { "name": "Frank de Jonge", "email": "info@frankdejonge.nl" } ] } ================================================ FILE: src/InvalidStreamProvided.php ================================================ detector->detectMimeType($path, $contents); } public function detectMimeTypeFromBuffer(string $contents): ?string { return $this->detector->detectMimeTypeFromBuffer($contents); } public function detectMimeTypeFromPath(string $path): ?string { return $this->detector->detectMimeTypeFromPath($path); } public function detectMimeTypeFromFile(string $path): ?string { $mimeType = $this->detector->detectMimeTypeFromFile($path); if ($mimeType !== null && ! in_array($mimeType, $this->inconclusiveMimetypes)) { return $mimeType; } return $this->detector->detectMimeTypeFromPath($path) ?? ($this->useInconclusiveMimeTypeFallback ? $mimeType : null); } } ================================================ FILE: src/Local/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/Local/LocalFilesystemAdapter.php ================================================ prefixer = new PathPrefixer($location, DIRECTORY_SEPARATOR); $visibility ??= new PortableVisibilityConverter(); $this->visibility = $visibility; $this->rootLocation = $location; $this->mimeTypeDetector = $mimeTypeDetector ?? new FallbackMimeTypeDetector( detector: new FinfoMimeTypeDetector(), useInconclusiveMimeTypeFallback: $useInconclusiveMimeTypeFallback, ); if ( ! $lazyRootCreation) { $this->ensureRootDirectoryExists(); } } private function ensureRootDirectoryExists(): void { if ($this->rootLocationIsSetup) { return; } $this->ensureDirectoryExists($this->rootLocation, $this->visibility->defaultForDirectories()); $this->rootLocationIsSetup = true; } public function write(string $path, string $contents, Config $config): void { $this->writeToFile($path, $contents, $config); } public function writeStream(string $path, $contents, Config $config): void { $this->writeToFile($path, $contents, $config); } /** * @param resource|string $contents */ private function writeToFile(string $path, $contents, Config $config): void { $prefixedLocation = $this->prefixer->prefixPath($path); $this->ensureRootDirectoryExists(); $this->ensureDirectoryExists( dirname($prefixedLocation), $this->resolveDirectoryVisibility($config->get(Config::OPTION_DIRECTORY_VISIBILITY)) ); error_clear_last(); if (@file_put_contents($prefixedLocation, $contents, $this->writeFlags) === false) { throw UnableToWriteFile::atLocation($path, error_get_last()['message'] ?? ''); } if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { $this->setVisibility($path, (string) $visibility); } } public function delete(string $path): void { $location = $this->prefixer->prefixPath($path); if ( ! file_exists($location)) { return; } error_clear_last(); if ( ! @unlink($location)) { throw UnableToDeleteFile::atLocation($location, error_get_last()['message'] ?? ''); } } public function deleteDirectory(string $prefix): void { $location = $this->prefixer->prefixPath($prefix); if ( ! is_dir($location)) { return; } $contents = $this->listDirectoryRecursively($location, RecursiveIteratorIterator::CHILD_FIRST); /** @var SplFileInfo $file */ foreach ($contents as $file) { if ( ! $this->deleteFileInfoObject($file)) { throw UnableToDeleteDirectory::atLocation($prefix, "Unable to delete file at " . $file->getPathname()); } } unset($contents); if ( ! @rmdir($location)) { throw UnableToDeleteDirectory::atLocation($prefix, error_get_last()['message'] ?? ''); } } private function listDirectoryRecursively( string $path, int $mode = RecursiveIteratorIterator::SELF_FIRST ): Generator { if ( ! is_dir($path)) { return; } yield from new RecursiveIteratorIterator( new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), $mode ); } protected function deleteFileInfoObject(SplFileInfo $file): bool { switch ($file->getType()) { case 'dir': return @rmdir((string) $file->getRealPath()); case 'link': return @unlink((string) $file->getPathname()); default: return @unlink((string) $file->getRealPath()); } } public function listContents(string $path, bool $deep): iterable { $location = $this->prefixer->prefixPath($path); if ( ! is_dir($location)) { return; } /** @var SplFileInfo[] $iterator */ $iterator = $deep ? $this->listDirectoryRecursively($location) : $this->listDirectory($location); foreach ($iterator as $fileInfo) { $pathName = $fileInfo->getPathname(); try { if ($fileInfo->isLink()) { if ($this->linkHandling & self::SKIP_LINKS) { continue; } throw SymbolicLinkEncountered::atLocation($pathName); } $path = $this->prefixer->stripPrefix($pathName); $lastModified = $fileInfo->getMTime(); $isDirectory = $fileInfo->isDir(); $permissions = octdec(substr(sprintf('%o', $fileInfo->getPerms()), -4)); $visibility = $isDirectory ? $this->visibility->inverseForDirectory($permissions) : $this->visibility->inverseForFile($permissions); yield $isDirectory ? new DirectoryAttributes(str_replace('\\', '/', $path), $visibility, $lastModified) : new FileAttributes( str_replace('\\', '/', $path), $fileInfo->getSize(), $visibility, $lastModified ); } catch (Throwable $exception) { if (file_exists($pathName)) { throw $exception; } } } } public function move(string $source, string $destination, Config $config): void { $sourcePath = $this->prefixer->prefixPath($source); $destinationPath = $this->prefixer->prefixPath($destination); $this->ensureRootDirectoryExists(); $this->ensureDirectoryExists( dirname($destinationPath), $this->resolveDirectoryVisibility($config->get(Config::OPTION_DIRECTORY_VISIBILITY)) ); error_clear_last(); if ( ! @rename($sourcePath, $destinationPath)) { throw UnableToMoveFile::because(error_get_last()['message'] ?? 'unknown reason', $source, $destination); } if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { $this->setVisibility($destination, (string) $visibility); } } public function copy(string $source, string $destination, Config $config): void { $sourcePath = $this->prefixer->prefixPath($source); $destinationPath = $this->prefixer->prefixPath($destination); $this->ensureRootDirectoryExists(); $this->ensureDirectoryExists( dirname($destinationPath), $this->resolveDirectoryVisibility($config->get(Config::OPTION_DIRECTORY_VISIBILITY)) ); error_clear_last(); if ($sourcePath !== $destinationPath && ! @copy($sourcePath, $destinationPath)) { throw UnableToCopyFile::because(error_get_last()['message'] ?? 'unknown', $source, $destination); } $visibility = $config->get( Config::OPTION_VISIBILITY, $config->get(Config::OPTION_RETAIN_VISIBILITY, true) ? $this->visibility($source)->visibility() : null, ); if ($visibility) { $this->setVisibility($destination, (string) $visibility); } } public function read(string $path): string { $location = $this->prefixer->prefixPath($path); error_clear_last(); $contents = @file_get_contents($location); if ($contents === false) { throw UnableToReadFile::fromLocation($path, error_get_last()['message'] ?? ''); } return $contents; } public function readStream(string $path) { $location = $this->prefixer->prefixPath($path); error_clear_last(); $contents = @fopen($location, 'rb'); if ($contents === false) { throw UnableToReadFile::fromLocation($path, error_get_last()['message'] ?? ''); } return $contents; } protected function ensureDirectoryExists(string $dirname, int $visibility): void { if (is_dir($dirname)) { return; } error_clear_last(); if ( ! @mkdir($dirname, $visibility, true)) { $mkdirError = error_get_last(); } clearstatcache(true, $dirname); if ( ! is_dir($dirname)) { $errorMessage = isset($mkdirError['message']) ? $mkdirError['message'] : ''; throw UnableToCreateDirectory::atLocation($dirname, $errorMessage); } } public function fileExists(string $location): bool { $location = $this->prefixer->prefixPath($location); clearstatcache(); return is_file($location); } public function directoryExists(string $location): bool { $location = $this->prefixer->prefixPath($location); clearstatcache(); return is_dir($location); } public function createDirectory(string $path, Config $config): void { $this->ensureRootDirectoryExists(); $location = $this->prefixer->prefixPath($path); $visibility = $config->get(Config::OPTION_VISIBILITY, $config->get(Config::OPTION_DIRECTORY_VISIBILITY)); $permissions = $this->resolveDirectoryVisibility($visibility); if (is_dir($location)) { $this->setPermissions($location, $permissions); return; } error_clear_last(); if ( ! @mkdir($location, $permissions, true)) { throw UnableToCreateDirectory::atLocation($path, error_get_last()['message'] ?? ''); } } public function setVisibility(string $path, string $visibility): void { $path = $this->prefixer->prefixPath($path); $visibility = is_dir($path) ? $this->visibility->forDirectory($visibility) : $this->visibility->forFile( $visibility ); $this->setPermissions($path, $visibility); } public function visibility(string $path): FileAttributes { $location = $this->prefixer->prefixPath($path); clearstatcache(false, $location); error_clear_last(); $fileperms = @fileperms($location); if ($fileperms === false) { throw UnableToRetrieveMetadata::visibility($path, error_get_last()['message'] ?? ''); } $permissions = $fileperms & 0777; $visibility = $this->visibility->inverseForFile($permissions); return new FileAttributes($path, null, $visibility); } private function resolveDirectoryVisibility(?string $visibility): int { return $visibility === null ? $this->visibility->defaultForDirectories() : $this->visibility->forDirectory( $visibility ); } public function mimeType(string $path): FileAttributes { $location = $this->prefixer->prefixPath($path); error_clear_last(); if ( ! is_file($location)) { throw UnableToRetrieveMetadata::mimeType($location, 'No such file exists.'); } $mimeType = $this->mimeTypeDetector->detectMimeTypeFromFile($location); if ($mimeType === null) { throw UnableToRetrieveMetadata::mimeType($path, error_get_last()['message'] ?? ''); } return new FileAttributes($path, null, null, null, $mimeType); } public function lastModified(string $path): FileAttributes { $location = $this->prefixer->prefixPath($path); clearstatcache(); error_clear_last(); $lastModified = @filemtime($location); if ($lastModified === false) { throw UnableToRetrieveMetadata::lastModified($path, error_get_last()['message'] ?? ''); } return new FileAttributes($path, null, null, $lastModified); } public function fileSize(string $path): FileAttributes { $location = $this->prefixer->prefixPath($path); clearstatcache(); error_clear_last(); if (is_file($location) && ($fileSize = @filesize($location)) !== false) { return new FileAttributes($path, $fileSize); } throw UnableToRetrieveMetadata::fileSize($path, error_get_last()['message'] ?? ''); } public function checksum(string $path, Config $config): string { $algo = $config->get('checksum_algo', 'md5'); $location = $this->prefixer->prefixPath($path); error_clear_last(); $checksum = @hash_file($algo, $location); if ($checksum === false) { throw new UnableToProvideChecksum(error_get_last()['message'] ?? '', $path); } return $checksum; } private function listDirectory(string $location): Generator { $iterator = new DirectoryIterator($location); foreach ($iterator as $item) { if ($item->isDot()) { continue; } yield $item; } } private function setPermissions(string $location, int $visibility): void { error_clear_last(); if ( ! @chmod($location, $visibility)) { $extraMessage = error_get_last()['message'] ?? ''; throw UnableToSetVisibility::atLocation($this->prefixer->stripPrefix($location), $extraMessage); } } } ================================================ FILE: src/Local/LocalFilesystemAdapterTest.php ================================================ assertDirectoryExists(static::ROOT); } /** * @test */ public function creating_a_local_filesystem_does_not_create_a_root_directory_when_constructed_with_lazy_root_creation(): void { new LocalFilesystemAdapter(static::ROOT, lazyRootCreation: true); $this->assertDirectoryDoesNotExist(static::ROOT); } /** * @test */ public function not_being_able_to_create_a_root_directory_results_in_an_exception(): void { $this->expectException(UnableToCreateDirectory::class); new LocalFilesystemAdapter('/cannot-create/this-directory/'); } /** * @test * * @see https://github.com/thephpleague/flysystem/issues/1442 */ public function falling_back_to_extension_lookup_when_finding_mime_type_of_empty_file(): void { $this->givenWeHaveAnExistingFile('something.csv', ''); $mimeType = $this->adapter()->mimeType('something.csv'); self::assertEquals('text/csv', $mimeType->mimeType()); } /** * @test */ public function writing_a_file(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('/file.txt', 'contents', new Config()); $this->assertFileExists(static::ROOT . '/file.txt'); $contents = file_get_contents(static::ROOT . '/file.txt'); $this->assertEquals('contents', $contents); } /** * @test */ public function writing_a_file_with_a_stream(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $stream = stream_with_contents('contents'); $adapter->writeStream('/file.txt', $stream, new Config()); fclose($stream); $this->assertFileExists(static::ROOT . '/file.txt'); $contents = file_get_contents(static::ROOT . '/file.txt'); $this->assertEquals('contents', $contents); } /** * @test * * @see https://github.com/thephpleague/flysystem/issues/1606 */ public function deleting_a_file_during_contents_listing(): void { $adapter = new LocalFilesystemAdapter(static::ROOT, visibility: new class() implements VisibilityConverter { private VisibilityConverter $visibility; public function __construct() { $this->visibility = new PortableVisibilityConverter(); } public function forFile(string $visibility): int { return $this->visibility->forFile($visibility); } public function forDirectory(string $visibility): int { return $this->visibility->forDirectory($visibility); } public function inverseForFile(int $visibility): string { unlink(LocalFilesystemAdapterTest::ROOT . '/file-1.txt'); return $this->visibility->inverseForFile($visibility); } public function inverseForDirectory(int $visibility): string { return $this->visibility->inverseForDirectory($visibility); } public function defaultForDirectories(): int { return $this->visibility->defaultForDirectories(); } }); $filesystem = new Filesystem($adapter); $filesystem->write('/file-1.txt', 'something'); $listing = $filesystem->listContents('/')->toArray(); self::assertCount(0, $listing); } /** * @test */ public function writing_a_file_with_a_stream_and_visibility(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $stream = stream_with_contents('something'); $adapter->writeStream('/file.txt', $stream, new Config(['visibility' => Visibility::PRIVATE])); fclose($stream); $this->assertFileContains(static::ROOT . '/file.txt', 'something'); $this->assertFileHasPermissions(static::ROOT . '/file.txt', 0600); } /** * @test */ public function writing_a_file_with_visibility(): void { $adapter = new LocalFilesystemAdapter(static::ROOT, new PortableVisibilityConverter()); $adapter->write('/file.txt', 'contents', new Config(['visibility' => 'private'])); $this->assertFileContains(static::ROOT . '/file.txt', 'contents'); $this->assertFileHasPermissions(static::ROOT . '/file.txt', 0600); } /** * @test */ public function failing_to_set_visibility(): void { $this->expectException(UnableToSetVisibility::class); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->setVisibility('/file.txt', Visibility::PUBLIC); } /** * @test */ public function failing_to_write_a_file(): void { $this->expectException(UnableToWriteFile::class); (new LocalFilesystemAdapter('/'))->write('/cannot-create-a-file-here', 'contents', new Config()); } /** * @test */ public function failing_to_write_a_file_using_a_stream(): void { $this->expectException(UnableToWriteFile::class); try { $stream = stream_with_contents('something'); (new LocalFilesystemAdapter('/'))->writeStream('/cannot-create-a-file-here', $stream, new Config()); } finally { isset($stream) && is_resource($stream) && fclose($stream); } } /** * @test */ public function deleting_a_file(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); file_put_contents(static::ROOT . '/file.txt', 'contents'); $adapter->delete('/file.txt'); $this->assertFileDoesNotExist(static::ROOT . '/file.txt'); } /** * @test */ public function deleting_a_file_that_does_not_exist(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->delete('/file.txt'); $this->assertTrue(true); } /** * @test */ public function deleting_a_file_that_cannot_be_deleted(): void { $this->givenWeHaveAnExistingFile('here.txt'); mock_function('unlink', false); $this->expectException(UnableToDeleteFile::class); $this->adapter()->delete('here.txt'); } /** * @test */ public function checking_if_a_file_exists(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('/file.txt', 'contents', new Config); $this->assertTrue($adapter->fileExists('/file.txt')); } /** * @test */ public function checking_if_a_file_exists_that_does_not_exsist(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $this->assertFalse($adapter->fileExists('/file.txt')); } /** * @test */ public function listing_contents(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('directory/filename.txt', 'content', new Config()); $adapter->write('filename.txt', 'content', new Config()); /** @var Traversable $contentListing */ $contentListing = $adapter->listContents('/', false); $contents = iterator_to_array($contentListing); $this->assertCount(2, $contents); $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $contents); } /** * @test */ public function listing_contents_recursively(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('directory/filename.txt', 'content', new Config()); $adapter->write('filename.txt', 'content', new Config()); /** @var Traversable $contentListing */ $contentListing = $adapter->listContents('/', true); $contents = iterator_to_array($contentListing); $this->assertCount(3, $contents); $this->assertContainsOnlyInstancesOf(StorageAttributes::class, $contents); } /** * @test */ public function listing_a_non_existing_directory(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); /** @var Traversable $contentListing */ $contentListing = $adapter->listContents('/directory/', false); $contents = iterator_to_array($contentListing); $this->assertCount(0, $contents); } /** * @test */ public function listing_directory_contents_with_link_skipping(): void { $adapter = new LocalFilesystemAdapter(static::ROOT, null, LOCK_EX, LocalFilesystemAdapter::SKIP_LINKS); $adapter->write('/file.txt', 'content', new Config()); symlink(static::ROOT . '/file.txt', static::ROOT . '/link.txt'); /** @var Traversable $contentListing */ $contentListing = $adapter->listContents('/', true); $contents = iterator_to_array($contentListing); $this->assertCount(1, $contents); } /** * @test */ public function listing_directory_contents_with_disallowing_links(): void { $this->expectException(SymbolicLinkEncountered::class); $adapter = new LocalFilesystemAdapter(static::ROOT, null, LOCK_EX, LocalFilesystemAdapter::DISALLOW_LINKS); file_put_contents(static::ROOT . '/file.txt', 'content'); symlink(static::ROOT . '/file.txt', static::ROOT . '/link.txt'); /** @var Traversable $contentListing */ $contentListing = $adapter->listContents('/', true); iterator_to_array($contentListing); } /** * @test */ public function retrieving_visibility_while_listing_directory_contents(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->createDirectory('public', new Config(['visibility' => 'public'])); $adapter->createDirectory('private', new Config(['visibility' => 'private'])); $adapter->write('public/private.txt', 'private', new Config(['visibility' => 'private'])); $adapter->write('private/public.txt', 'public', new Config(['visibility' => 'public'])); /** @var Traversable $contentListing */ $contentListing = $adapter->listContents('/', true); $listing = iterator_to_array($contentListing); usort($listing, function (StorageAttributes $a, StorageAttributes $b) { return strnatcasecmp($a->path(), $b->path()); }); /** * @var StorageAttributes $publicDirectoryAttributes * @var StorageAttributes $privateFileAttributes * @var StorageAttributes $privateDirectoryAttributes * @var StorageAttributes $publicFileAttributes */ [$privateDirectoryAttributes, $publicFileAttributes, $publicDirectoryAttributes, $privateFileAttributes] = $listing; $this->assertEquals('public', $publicDirectoryAttributes->visibility()); $this->assertEquals('private', $privateFileAttributes->visibility()); $this->assertEquals('private', $privateDirectoryAttributes->visibility()); $this->assertEquals('public', $publicFileAttributes->visibility()); } /** * @test */ public function deleting_a_directory(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); mkdir(static::ROOT . '/directory/subdir/', 0744, true); $this->assertDirectoryExists(static::ROOT . '/directory/subdir/'); file_put_contents(static::ROOT . '/directory/subdir/file.txt', 'content'); symlink(static::ROOT . '/directory/subdir/file.txt', static::ROOT . '/directory/subdir/link.txt'); $adapter->deleteDirectory('directory/subdir'); $this->assertDirectoryDoesNotExist(static::ROOT . '/directory/subdir/'); $adapter->deleteDirectory('directory'); $this->assertDirectoryDoesNotExist(static::ROOT . '/directory/'); } /** * @test */ public function deleting_directories_with_other_directories_in_it(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('a/b/c/d/e.txt', 'contents', new Config()); $adapter->deleteDirectory('a/b'); $this->assertDirectoryExists(static::ROOT . '/a'); $this->assertDirectoryDoesNotExist(static::ROOT . '/a/b'); } /** * @test */ public function deleting_a_non_existing_directory(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->deleteDirectory('/non-existing-directory/'); $this->assertTrue(true); } /** * @test */ public function not_being_able_to_delete_a_directory(): void { $this->expectException(UnableToDeleteDirectory::class); mock_function('rmdir', false); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->createDirectory('/etc/', new Config()); $adapter->deleteDirectory('/etc/'); } /** * @test */ public function not_being_able_to_delete_a_sub_directory(): void { $this->expectException(UnableToDeleteDirectory::class); mock_function('rmdir', false); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->createDirectory('/etc/subdirectory/', new Config()); $adapter->deleteDirectory('/etc/'); } /** * @test */ public function creating_a_directory(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->createDirectory('public', new Config(['visibility' => 'public'])); $this->assertDirectoryExists(static::ROOT . '/public'); $this->assertFileHasPermissions(static::ROOT . '/public', 0755); $adapter->createDirectory('private', new Config(['visibility' => 'private'])); $this->assertDirectoryExists(static::ROOT . '/private'); $this->assertFileHasPermissions(static::ROOT . '/private', 0700); $adapter->createDirectory('also_private', new Config(['directory_visibility' => 'private'])); $this->assertDirectoryExists(static::ROOT . '/also_private'); $this->assertFileHasPermissions(static::ROOT . '/also_private', 0700); } /** * @test */ public function not_being_able_to_create_a_directory(): void { $this->expectException(UnableToCreateDirectory::class); $adapter = new LocalFilesystemAdapter('/'); $adapter->createDirectory('/something/', new Config()); } /** * @test */ public function creating_a_directory_is_idempotent(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->createDirectory('/something/', new Config(['visibility' => 'private'])); $this->assertFileHasPermissions(static::ROOT . '/something', 0700); $adapter->createDirectory('/something/', new Config(['visibility' => 'public'])); $this->assertFileHasPermissions(static::ROOT . '/something', 0755); } /** * @test */ public function retrieving_visibility(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('public.txt', 'contents', new Config(['visibility' => 'public'])); $this->assertEquals('public', $adapter->visibility('public.txt')->visibility()); $adapter->write('private.txt', 'contents', new Config(['visibility' => 'private'])); $this->assertEquals('private', $adapter->visibility('private.txt')->visibility()); } /** * @test */ public function not_being_able_to_retrieve_visibility(): void { $this->expectException(UnableToRetrieveMetadata::class); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->visibility('something.txt'); } /** * @test */ public function moving_a_file(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('first.txt', 'contents', new Config()); $this->assertFileExists(static::ROOT . '/first.txt'); $adapter->move('first.txt', 'second.txt', new Config()); $this->assertFileExists(static::ROOT . '/second.txt'); $this->assertFileDoesNotExist(static::ROOT . '/first.txt'); } /** * @test */ public function moving_a_file_with_visibility(): void { $adapter = new LocalFilesystemAdapter(static::ROOT, new PortableVisibilityConverter()); $adapter->write('first.txt', 'contents', new Config()); $this->assertFileExists(static::ROOT . '/first.txt'); $this->assertFileHasPermissions(static::ROOT . '/first.txt', 0644); $adapter->move('first.txt', 'second.txt', new Config(['visibility' => 'private'])); $this->assertFileExists(static::ROOT . '/second.txt'); $this->assertFileHasPermissions(static::ROOT . '/second.txt', 0600); } /** * @test */ public function not_being_able_to_move_a_file(): void { $this->expectException(UnableToMoveFile::class); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->move('first.txt', 'second.txt', new Config()); } /** * @test */ public function copying_a_file(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('first.txt', 'contents', new Config()); $adapter->copy('first.txt', 'second.txt', new Config()); $this->assertFileExists(static::ROOT . '/second.txt'); $this->assertFileExists(static::ROOT . '/first.txt'); } /** * @test */ public function copying_a_file_with_visibility(): void { $adapter = new LocalFilesystemAdapter(static::ROOT, new PortableVisibilityConverter()); $adapter->write('first.txt', 'contents', new Config()); $adapter->copy('first.txt', 'second.txt', new Config(['visibility' => 'private'])); $this->assertFileExists(static::ROOT . '/first.txt'); $this->assertFileHasPermissions(static::ROOT . '/first.txt', 0644); $this->assertFileExists(static::ROOT . '/second.txt'); $this->assertFileHasPermissions(static::ROOT . '/second.txt', 0600); } /** * @test */ public function copying_a_file_retaining_visibility(): void { $adapter = new LocalFilesystemAdapter(static::ROOT, new PortableVisibilityConverter()); $adapter->write('first.txt', 'contents', new Config(['visibility' => 'private'])); $adapter->copy('first.txt', 'retain.txt', new Config()); $adapter->copy('first.txt', 'do-not-retain.txt', new Config(['retain_visibility' => false])); $this->assertFileExists(static::ROOT . '/first.txt'); $this->assertFileHasPermissions(static::ROOT . '/first.txt', 0600); $this->assertFileExists(static::ROOT . '/retain.txt'); $this->assertFileHasPermissions(static::ROOT . '/retain.txt', 0600); $this->assertFileExists(static::ROOT . '/do-not-retain.txt'); $this->assertFileHasPermissions(static::ROOT . '/do-not-retain.txt', 0644); } /** * @test */ public function not_being_able_to_copy_a_file(): void { $this->expectException(UnableToCopyFile::class); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->copy('first.txt', 'second.txt', new Config()); } /** * @test */ public function getting_mimetype(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write( 'flysystem.svg', (string) file_get_contents(__DIR__ . '/../AdapterTestUtilities/test_files/flysystem.svg'), new Config() ); $this->assertStringStartsWith('image/svg+xml', $adapter->mimeType('flysystem.svg')->mimeType()); } /** * @test */ public function failing_to_get_the_mimetype(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write( 'file.unknown', '', new Config() ); $this->expectException(UnableToRetrieveMetadata::class); $adapter->mimeType('file.unknown'); } /** * @test */ public function allowing_inconclusive_mime_type(): void { $adapter = new LocalFilesystemAdapter( location: static::ROOT, useInconclusiveMimeTypeFallback: true, ); $adapter->write( 'file.unknown', '', new Config() ); $this->assertEquals('application/x-empty', $adapter->mimeType('file.unknown')->mimeType()); } /** * @test */ public function fetching_unknown_mime_type_of_a_file(): void { $this->useAdapter(new LocalFilesystemAdapter(self::ROOT, null, LOCK_EX, LocalFilesystemAdapter::DISALLOW_LINKS, new ExtensionMimeTypeDetector(new EmptyExtensionToMimeTypeMap()))); parent::fetching_unknown_mime_type_of_a_file(); } /** * @test */ public function not_being_able_to_get_mimetype(): void { $this->expectException(UnableToRetrieveMetadata::class); $adapter = new LocalFilesystemAdapter( location: static::ROOT, mimeTypeDetector: new FinfoMimeTypeDetector(), ); $adapter->mimeType('flysystem.svg'); } /** * @test */ public function getting_last_modified(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('first.txt', 'contents', new Config()); mock_function('filemtime', $now = time()); $lastModified = $adapter->lastModified('first.txt')->lastModified(); $this->assertEquals($now, $lastModified); } /** * @test */ public function not_being_able_to_get_last_modified(): void { $this->expectException(UnableToRetrieveMetadata::class); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->lastModified('first.txt'); } /** * @test */ public function getting_file_size(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('first.txt', 'contents', new Config()); $fileSize = $adapter->fileSize('first.txt'); $this->assertEquals(8, $fileSize->fileSize()); } /** * @test */ public function not_being_able_to_get_file_size(): void { $this->expectException(UnableToRetrieveMetadata::class); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->fileSize('first.txt'); } /** * @test */ public function reading_a_file(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('path.txt', 'contents', new Config()); $contents = $adapter->read('path.txt'); $this->assertEquals('contents', $contents); } /** * @test */ public function not_being_able_to_read_a_file(): void { $this->expectException(UnableToReadFile::class); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->read('path.txt'); } /** * @test */ public function reading_a_stream(): void { $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->write('path.txt', 'contents', new Config()); $contents = $adapter->readStream('path.txt'); $this->assertIsResource($contents); $fileContents = stream_get_contents($contents); fclose($contents); $this->assertEquals('contents', $fileContents); } /** * @test */ public function not_being_able_to_stream_read_a_file(): void { $this->expectException(UnableToReadFile::class); $adapter = new LocalFilesystemAdapter(static::ROOT); $adapter->readStream('path.txt'); } /* ////////////////////// // These are the utils // ////////////////////// */ /** * @param string $file * @param int $expectedPermissions */ private function assertFileHasPermissions(string $file, int $expectedPermissions): void { clearstatcache(false, $file); $permissions = fileperms($file) & 0777; $this->assertEquals($expectedPermissions, $permissions); } /** * @param string $file * @param string $expectedContents */ private function assertFileContains(string $file, string $expectedContents): void { $this->assertFileExists($file); $contents = file_get_contents($file); $this->assertEquals($expectedContents, $contents); } protected static function createFilesystemAdapter(): FilesystemAdapter { return new LocalFilesystemAdapter(static::ROOT); } /** * @test */ public function get_checksum_with_specified_algo(): void { /** @var LocalFilesystemAdapter $adapter */ $adapter = $this->adapter(); $adapter->write('path.txt', 'foobar', new Config()); $checksum = $adapter->checksum('path.txt', new Config(['checksum_algo' => 'crc32c'])); $this->assertSame('0d5f5c7f', $checksum); } } ================================================ FILE: src/Local/README.md ================================================ ## Sub-split of Flysystem for local file storage. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-local ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/local/). ================================================ FILE: src/Local/composer.json ================================================ { "name": "league/flysystem-local", "description": "Local filesystem adapter for Flysystem.", "keywords": ["flysystem", "filesystem", "local", "file", "files"], "type": "library", "prefer-stable": true, "autoload": { "psr-4": { "League\\Flysystem\\Local\\": "" } }, "require": { "php": "^8.0.2", "ext-fileinfo": "*", "league/flysystem": "^3.0.0", "league/mime-type-detection": "^1.0.0" }, "license": "MIT", "authors": [ { "name": "Frank de Jonge", "email": "info@frankdejonge.nl" } ] } ================================================ FILE: src/MountManager.php ================================================ */ private $filesystems = []; /** * @var Config */ private $config; /** * MountManager constructor. * * @param array $filesystems */ public function __construct(array $filesystems = [], array $config = []) { $this->mountFilesystems($filesystems); $this->config = new Config($config); } /** * It is not recommended to mount filesystems after creation because interacting * with the Mount Manager becomes unpredictable. Use this as an escape hatch. */ public function dangerouslyMountFilesystems(string $key, FilesystemOperator $filesystem): void { $this->mountFilesystem($key, $filesystem); } /** * @param array $filesystems */ public function extend(array $filesystems, array $config = []): MountManager { $clone = clone $this; $clone->config = $this->config->extend($config); $clone->mountFilesystems($filesystems); return $clone; } public function fileExists(string $location): bool { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { return $filesystem->fileExists($path); } catch (Throwable $exception) { throw UnableToCheckFileExistence::forLocation($location, $exception); } } public function has(string $location): bool { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { return $filesystem->fileExists($path) || $filesystem->directoryExists($path); } catch (Throwable $exception) { throw UnableToCheckExistence::forLocation($location, $exception); } } public function directoryExists(string $location): bool { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { return $filesystem->directoryExists($path); } catch (Throwable $exception) { throw UnableToCheckDirectoryExistence::forLocation($location, $exception); } } public function read(string $location): string { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { return $filesystem->read($path); } catch (UnableToReadFile $exception) { throw UnableToReadFile::fromLocation($location, $exception->reason(), $exception); } } public function readStream(string $location) { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { return $filesystem->readStream($path); } catch (UnableToReadFile $exception) { throw UnableToReadFile::fromLocation($location, $exception->reason(), $exception); } } public function listContents(string $location, bool $deep = self::LIST_SHALLOW): DirectoryListing { /** @var FilesystemOperator $filesystem */ [$filesystem, $path, $mountIdentifier] = $this->determineFilesystemAndPath($location); return $filesystem ->listContents($path, $deep) ->map( function (StorageAttributes $attributes) use ($mountIdentifier) { return $attributes->withPath(sprintf('%s://%s', $mountIdentifier, $attributes->path())); } ); } public function lastModified(string $location): int { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { return $filesystem->lastModified($path); } catch (UnableToRetrieveMetadata $exception) { throw UnableToRetrieveMetadata::lastModified($location, $exception->reason(), $exception); } } public function fileSize(string $location): int { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { return $filesystem->fileSize($path); } catch (UnableToRetrieveMetadata $exception) { throw UnableToRetrieveMetadata::fileSize($location, $exception->reason(), $exception); } } public function mimeType(string $location): string { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { return $filesystem->mimeType($path); } catch (UnableToRetrieveMetadata $exception) { throw UnableToRetrieveMetadata::mimeType($location, $exception->reason(), $exception); } } public function visibility(string $path): string { /** @var FilesystemOperator $filesystem */ [$filesystem, $location] = $this->determineFilesystemAndPath($path); try { return $filesystem->visibility($location); } catch (UnableToRetrieveMetadata $exception) { throw UnableToRetrieveMetadata::visibility($path, $exception->reason(), $exception); } } public function write(string $location, string $contents, array $config = []): void { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { $filesystem->write($path, $contents, $this->config->extend($config)->toArray()); } catch (UnableToWriteFile $exception) { throw UnableToWriteFile::atLocation($location, $exception->reason(), $exception); } } public function writeStream(string $location, $contents, array $config = []): void { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); $filesystem->writeStream($path, $contents, $this->config->extend($config)->toArray()); } public function setVisibility(string $path, string $visibility): void { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($path); $filesystem->setVisibility($path, $visibility); } public function delete(string $location): void { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { $filesystem->delete($path); } catch (UnableToDeleteFile $exception) { throw UnableToDeleteFile::atLocation($location, $exception->reason(), $exception); } } public function deleteDirectory(string $location): void { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { $filesystem->deleteDirectory($path); } catch (UnableToDeleteDirectory $exception) { throw UnableToDeleteDirectory::atLocation($location, $exception->reason(), $exception); } } public function createDirectory(string $location, array $config = []): void { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($location); try { $filesystem->createDirectory($path, $this->config->extend($config)->toArray()); } catch (UnableToCreateDirectory $exception) { throw UnableToCreateDirectory::dueToFailure($location, $exception); } } public function move(string $source, string $destination, array $config = []): void { /** @var FilesystemOperator $sourceFilesystem */ /* @var FilesystemOperator $destinationFilesystem */ [$sourceFilesystem, $sourcePath] = $this->determineFilesystemAndPath($source); [$destinationFilesystem, $destinationPath] = $this->determineFilesystemAndPath($destination); $sourceFilesystem === $destinationFilesystem ? $this->moveInTheSameFilesystem( $sourceFilesystem, $sourcePath, $destinationPath, $source, $destination, $config, ) : $this->moveAcrossFilesystems($source, $destination, $config); } public function copy(string $source, string $destination, array $config = []): void { /** @var FilesystemOperator $sourceFilesystem */ /* @var FilesystemOperator $destinationFilesystem */ [$sourceFilesystem, $sourcePath] = $this->determineFilesystemAndPath($source); [$destinationFilesystem, $destinationPath] = $this->determineFilesystemAndPath($destination); $sourceFilesystem === $destinationFilesystem ? $this->copyInSameFilesystem( $sourceFilesystem, $sourcePath, $destinationPath, $source, $destination, $config, ) : $this->copyAcrossFilesystem( $sourceFilesystem, $sourcePath, $destinationFilesystem, $destinationPath, $source, $destination, $config, ); } public function publicUrl(string $path, array $config = []): string { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($path); if ( ! method_exists($filesystem, 'publicUrl')) { throw new UnableToGeneratePublicUrl(sprintf('%s does not support generating public urls.', $filesystem::class), $path); } return $filesystem->publicUrl($path, $config); } public function temporaryUrl(string $path, DateTimeInterface $expiresAt, array $config = []): string { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($path); if ( ! method_exists($filesystem, 'temporaryUrl')) { throw new UnableToGenerateTemporaryUrl(sprintf('%s does not support generating public urls.', $filesystem::class), $path); } return $filesystem->temporaryUrl($path, $expiresAt, $this->config->extend($config)->toArray()); } public function checksum(string $path, array $config = []): string { /** @var FilesystemOperator $filesystem */ [$filesystem, $path] = $this->determineFilesystemAndPath($path); if ( ! method_exists($filesystem, 'checksum')) { throw new UnableToProvideChecksum(sprintf('%s does not support providing checksums.', $filesystem::class), $path); } return $filesystem->checksum($path, $this->config->extend($config)->toArray()); } private function mountFilesystems(array $filesystems): void { foreach ($filesystems as $key => $filesystem) { $this->guardAgainstInvalidMount($key, $filesystem); /* @var string $key */ /* @var FilesystemOperator $filesystem */ $this->mountFilesystem($key, $filesystem); } } private function guardAgainstInvalidMount(mixed $key, mixed $filesystem): void { if ( ! is_string($key)) { throw UnableToMountFilesystem::becauseTheKeyIsNotValid($key); } if ( ! $filesystem instanceof FilesystemOperator) { throw UnableToMountFilesystem::becauseTheFilesystemWasNotValid($filesystem); } } private function mountFilesystem(string $key, FilesystemOperator $filesystem): void { $this->filesystems[$key] = $filesystem; } /** * @param string $path * * @return array{0:FilesystemOperator, 1:string, 2:string} */ private function determineFilesystemAndPath(string $path): array { if (strpos($path, '://') < 1) { throw UnableToResolveFilesystemMount::becauseTheSeparatorIsMissing($path); } /** @var string $mountIdentifier */ /** @var string $mountPath */ [$mountIdentifier, $mountPath] = explode('://', $path, 2); if ( ! array_key_exists($mountIdentifier, $this->filesystems)) { throw UnableToResolveFilesystemMount::becauseTheMountWasNotRegistered($mountIdentifier); } return [$this->filesystems[$mountIdentifier], $mountPath, $mountIdentifier]; } private function copyInSameFilesystem( FilesystemOperator $sourceFilesystem, string $sourcePath, string $destinationPath, string $source, string $destination, array $config, ): void { try { $sourceFilesystem->copy($sourcePath, $destinationPath, $this->config->extend($config)->toArray()); } catch (UnableToCopyFile $exception) { throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } private function copyAcrossFilesystem( FilesystemOperator $sourceFilesystem, string $sourcePath, FilesystemOperator $destinationFilesystem, string $destinationPath, string $source, string $destination, array $config, ): void { $config = $this->config->extend($config); $retainVisibility = (bool) $config->get(Config::OPTION_RETAIN_VISIBILITY, true); $visibility = $config->get(Config::OPTION_VISIBILITY); try { if ($visibility == null && $retainVisibility) { $visibility = $sourceFilesystem->visibility($sourcePath); $config = $config->extend(compact('visibility')); } $stream = $sourceFilesystem->readStream($sourcePath); $destinationFilesystem->writeStream($destinationPath, $stream, $config->toArray()); } catch (UnableToRetrieveMetadata | UnableToReadFile | UnableToWriteFile $exception) { throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } private function moveInTheSameFilesystem( FilesystemOperator $sourceFilesystem, string $sourcePath, string $destinationPath, string $source, string $destination, array $config, ): void { try { $sourceFilesystem->move($sourcePath, $destinationPath, $this->config->extend($config)->toArray()); } catch (UnableToMoveFile $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } } private function moveAcrossFilesystems(string $source, string $destination, array $config = []): void { try { $this->copy($source, $destination, $config); $this->delete($source); } catch (UnableToCopyFile | UnableToDeleteFile $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } } } ================================================ FILE: src/MountManagerTest.php ================================================ firstStubAdapter = new ExceptionThrowingFilesystemAdapter($firstFilesystemAdapter); $this->secondStubAdapter = new ExceptionThrowingFilesystemAdapter($secondFilesystemAdapter); $this->mountManager = new MountManager([ 'first' => $this->firstFilesystem = new Filesystem($this->firstStubAdapter), 'second' => $this->secondFilesystem = new Filesystem($this->secondStubAdapter), ]); } /** * @test */ public function copying_without_retaining_visibility(): void { // arrange $firstFilesystemAdapter = new InMemoryFilesystemAdapter(); $secondFilesystemAdapter = new InMemoryFilesystemAdapter(); $mountManager = new MountManager([ 'first' => new Filesystem($firstFilesystemAdapter, ['visibility' => 'public']), 'second' => new Filesystem($secondFilesystemAdapter, ['visibility' => 'private']), ], ['retain_visibility' => false]); // act $mountManager->write('first://file.txt', 'contents'); $mountManager->copy('first://file.txt', 'second://file.txt'); // assert $visibility = $mountManager->visibility('second://file.txt'); self::assertEquals('private', $visibility); } /** * @test */ public function extending_without_new_mounts_is_equal_but_not_the_same(): void { $mountManager = $this->mountManager->extend([]); $this->assertNotSame($this->mountManager, $mountManager); $this->assertEquals($this->mountManager, $mountManager); } /** * @test */ public function extending_with_new_mounts_is_not_equal(): void { $mountManager = $this->mountManager->extend([ 'third' => new Filesystem(new InMemoryFilesystemAdapter()), ]); $this->assertNotEquals($this->mountManager, $mountManager); } /** * @test */ public function extending_exposes_a_usable_mount_on_the_extension(): void { $mountManager = $this->mountManager->extend([ 'third' => new Filesystem(new InMemoryFilesystemAdapter()), ]); $mountManager->write('third://path.txt', 'this'); $contents = $mountManager->read('third://path.txt'); $this->assertEquals('this', $contents); } /** * @test */ public function extending_does_not_mount_on_the_original_mount_manager(): void { $this->mountManager->extend([ 'third' => new Filesystem(new InMemoryFilesystemAdapter()), ]); $this->expectException(UnableToResolveFilesystemMount::class); $this->mountManager->write('third://path.txt', 'this'); } /** * @test */ public function copying_while_retaining_visibility(): void { // arrange $firstFilesystemAdapter = new InMemoryFilesystemAdapter(); $secondFilesystemAdapter = new InMemoryFilesystemAdapter(); $mountManager = new MountManager([ 'first' => new Filesystem($firstFilesystemAdapter, ['visibility' => 'public']), 'second' => new Filesystem($secondFilesystemAdapter, ['visibility' => 'private']), ], ['retain_visibility' => true]); // act $mountManager->write('first://file.txt', 'contents'); $mountManager->copy('first://file.txt', 'second://file.txt'); // assert $visibility = $mountManager->visibility('second://file.txt'); self::assertEquals('public', $visibility); } /** * @test */ public function writing_a_file(): void { $this->mountManager->write('first://file.txt', 'content'); $this->mountManager->write('second://another-file.txt', 'content'); $this->assertTrue($this->firstFilesystem->fileExists('file.txt')); $this->assertFalse($this->secondFilesystem->fileExists('file.txt')); $this->assertFalse($this->firstFilesystem->fileExists('another-file.txt')); $this->assertTrue($this->secondFilesystem->fileExists('another-file.txt')); } /** * @test */ public function writing_a_file_with_a_stream(): void { $stream = stream_with_contents('contents'); $this->mountManager->writeStream('first://location.txt', $stream); $this->assertTrue($this->firstFilesystem->fileExists('location.txt')); $this->assertEquals('contents', $this->firstFilesystem->read('location.txt')); } /** * @test */ public function not_being_able_to_write_a_file(): void { $this->firstStubAdapter->stageException('write', 'file.txt', UnableToWriteFile::atLocation('file.txt')); $this->expectException(UnableToWriteFile::class); $this->mountManager->write('first://file.txt', 'content'); } /** * @test */ public function not_being_able_to_stream_write_a_file(): void { $handle = tmpfile(); $this->firstStubAdapter->stageException('writeStream', 'file.txt', UnableToWriteFile::atLocation('file.txt')); $this->expectException(UnableToWriteFile::class); try { $this->mountManager->writeStream('first://file.txt', $handle); } finally { is_resource($handle) && fclose($handle); } } /** * @description This test method is so ugly, but I don't have the energy to create a nice test for every single one of these method. * * @test * * @dataProvider dpMetadataRetrieverMethods */ public function failing_a_one_param_method(string $method, FilesystemOperationFailed $exception): void { $this->firstStubAdapter->stageException($method, 'location.txt', $exception); $this->expectException(get_class($exception)); $this->mountManager->{$method}('first://location.txt'); } public static function dpMetadataRetrieverMethods(): iterable { yield 'mimeType' => ['mimeType', UnableToRetrieveMetadata::mimeType('location.txt')]; yield 'fileSize' => ['fileSize', UnableToRetrieveMetadata::fileSize('location.txt')]; yield 'lastModified' => ['lastModified', UnableToRetrieveMetadata::lastModified('location.txt')]; yield 'visibility' => ['visibility', UnableToRetrieveMetadata::visibility('location.txt')]; yield 'delete' => ['delete', UnableToDeleteFile::atLocation('location.txt')]; yield 'deleteDirectory' => ['deleteDirectory', UnableToDeleteDirectory::atLocation('location.txt')]; yield 'createDirectory' => ['createDirectory', UnableToCreateDirectory::atLocation('location.txt')]; yield 'read' => ['read', UnableToReadFile::fromLocation('location.txt')]; yield 'readStream' => ['readStream', UnableToReadFile::fromLocation('location.txt')]; yield 'fileExists' => ['fileExists', UnableToCheckFileExistence::forLocation('location.txt')]; } /** * @test */ public function reading_a_file(): void { $this->secondFilesystem->write('location.txt', 'contents'); $contents = $this->mountManager->read('second://location.txt'); $this->assertEquals('contents', $contents); } /** * @test */ public function reading_a_file_as_a_stream(): void { $this->secondFilesystem->write('location.txt', 'contents'); $handle = $this->mountManager->readStream('second://location.txt'); $contents = stream_get_contents($handle); fclose($handle); $this->assertEquals('contents', $contents); } /** * @test */ public function checking_existence_for_an_existing_file(): void { $this->secondFilesystem->write('location.txt', 'contents'); $existence = $this->mountManager->fileExists('second://location.txt'); $this->assertTrue($existence); } /** * @test */ public function checking_existence_for_an_non_existing_file(): void { $existence = $this->mountManager->fileExists('second://location.txt'); $this->assertFalse($existence); } /** * @test */ public function checking_existence_for_an_non_existing_directory(): void { $existence = $this->mountManager->directoryExists('second://some-directory'); $this->assertFalse($existence); } /** * @test */ public function checking_existence_for_an_existing_directory(): void { $this->secondFilesystem->write('nested/location.txt', 'contents'); $existence = $this->mountManager->directoryExists('second://nested'); $this->assertTrue($existence); } /** * @test */ public function checking_existence_for_an_existing_file_using_has(): void { $this->secondFilesystem->write('location.txt', 'contents'); $existence = $this->mountManager->has('second://location.txt'); $this->assertTrue($existence); } /** * @test */ public function checking_existence_for_an_non_existing_file_using_has(): void { $existence = $this->mountManager->has('second://location.txt'); $this->assertFalse($existence); } /** * @test */ public function checking_existence_for_an_non_existing_directory_using_has(): void { $existence = $this->mountManager->has('second://some-directory'); $this->assertFalse($existence); } /** * @test */ public function checking_existence_for_an_existing_directory_using_has(): void { $this->secondFilesystem->write('nested/location.txt', 'contents'); $existence = $this->mountManager->has('second://nested'); $this->assertTrue($existence); } /** * @test */ public function deleting_a_file(): void { $this->firstFilesystem->write('location.txt', 'contents'); $this->mountManager->delete('first://location.txt'); $this->assertFalse($this->firstFilesystem->fileExists('location.txt')); } /** * @test */ public function deleting_a_directory(): void { $this->firstFilesystem->write('dirname/location.txt', 'contents'); $this->mountManager->deleteDirectory('first://dirname'); $this->assertFalse($this->firstFilesystem->fileExists('dirname/location.txt')); } /** * @test */ public function setting_visibility(): void { $this->firstFilesystem->write('location.txt', 'contents'); $this->firstFilesystem->setVisibility('location.txt', Visibility::PRIVATE); $this->mountManager->setVisibility('first://location.txt', Visibility::PUBLIC); $this->assertEquals(Visibility::PUBLIC, $this->firstFilesystem->visibility('location.txt')); } /** * @test */ public function retrieving_metadata(): void { $now = time(); $this->firstFilesystem->write('location.txt', 'contents'); $lastModified = $this->mountManager->lastModified('first://location.txt'); $fileSize = $this->mountManager->fileSize('first://location.txt'); $mimeType = $this->mountManager->mimeType('first://location.txt'); $this->assertGreaterThanOrEqual($now, $lastModified); $this->assertEquals(8, $fileSize); $this->assertEquals('text/plain', $mimeType); } /** * @test */ public function creating_a_directory(): void { $this->mountManager->createDirectory('first://directory'); $directoryListing = $this->firstFilesystem->listContents('/') ->toArray(); $this->assertCount(1, $directoryListing); /** @var DirectoryAttributes $directory */ $directory = $directoryListing[0]; $this->assertInstanceOf(DirectoryAttributes::class, $directory); $this->assertEquals('directory', $directory->path()); } /** * @test */ public function list_directory(): void { $this->mountManager->createDirectory('first://directory'); $this->mountManager->write('first://directory/file', 'foo'); $directoryListing = $this->mountManager->listContents('first://', Filesystem::LIST_DEEP)->toArray(); $this->assertCount(2, $directoryListing); /** @var DirectoryAttributes $directory */ $directory = $directoryListing[0]; $this->assertInstanceOf(DirectoryAttributes::class, $directory); $this->assertEquals('first://directory', $directory->path()); /** @var FileAttributes $file */ $file = $directoryListing[1]; $this->assertInstanceOf(FileAttributes::class, $file); $this->assertEquals('first://directory/file', $file->path()); } /** * @test */ public function copying_in_the_same_filesystem(): void { $this->firstFilesystem->write('location.txt', 'contents'); $this->assertTrue($this->firstFilesystem->fileExists('location.txt')); $this->mountManager->copy('first://location.txt', 'first://new-location.txt'); $this->assertTrue($this->firstFilesystem->fileExists('location.txt')); $this->assertTrue($this->firstFilesystem->fileExists('new-location.txt')); } /** * @test */ public function failing_to_copy_in_the_same_filesystem(): void { $this->firstFilesystem->write('location.txt', 'contents'); $this->firstStubAdapter->stageException('copy', 'location.txt', UnableToCopyFile::fromLocationTo('a', 'b')); $this->expectException(UnableToCopyFile::class); $this->mountManager->copy('first://location.txt', 'first://new-location.txt'); } /** * @test */ public function failing_to_move_in_the_same_filesystem(): void { $this->firstFilesystem->write('location.txt', 'contents'); $this->firstStubAdapter->stageException('move', 'location.txt', UnableToMoveFile::fromLocationTo('a', 'b')); $this->expectException(UnableToMoveFile::class); $this->mountManager->move('first://location.txt', 'first://new-location.txt'); } /** * @test */ public function moving_in_the_same_filesystem(): void { $this->firstFilesystem->write('location.txt', 'contents'); $this->assertTrue($this->firstFilesystem->fileExists('location.txt')); $this->mountManager->move('first://location.txt', 'first://new-location.txt'); $this->assertFalse($this->firstFilesystem->fileExists('location.txt')); $this->assertTrue($this->firstFilesystem->fileExists('new-location.txt')); } /** * @test */ public function moving_across_filesystem(): void { $this->firstFilesystem->write('location.txt', 'contents'); $this->assertTrue($this->firstFilesystem->fileExists('location.txt')); $this->mountManager->move('first://location.txt', 'second://new-location.txt'); $this->assertFalse($this->firstFilesystem->fileExists('location.txt')); $this->assertTrue($this->secondFilesystem->fileExists('new-location.txt')); } /** * @test */ public function failing_to_move_across_filesystem(): void { $this->firstFilesystem->write('location.txt', 'contents'); $this->firstStubAdapter->stageException('visibility', 'location.txt', UnableToRetrieveMetadata::visibility('location.txt')); $this->expectException(UnableToMoveFile::class); $this->mountManager->move('first://location.txt', 'second://new-location.txt'); } /** * @test */ public function failing_to_copy_across_filesystem(): void { $this->firstFilesystem->write('location.txt', 'contents'); $this->firstStubAdapter->stageException('visibility', 'location.txt', UnableToRetrieveMetadata::visibility('location.txt')); $this->expectException(UnableToCopyFile::class); $this->mountManager->copy('first://location.txt', 'second://new-location.txt'); } /** * @test */ public function listing_contents(): void { $this->firstFilesystem->write('contents.txt', 'file contents'); $this->firstFilesystem->write('dirname/contents.txt', 'file contents'); $this->secondFilesystem->write('dirname/contents.txt', 'file contents'); $contents = $this->mountManager->listContents('first://', FilesystemReader::LIST_DEEP)->toArray(); $this->assertCount(3, $contents); } /** * @test */ public function dangerously_mounting_additional_filesystems(): void { $this->firstFilesystem->write('contents.txt', 'file contents'); $this->mountManager->dangerouslyMountFilesystems('unknown', $this->firstFilesystem); $this->assertTrue($this->mountManager->fileExists('unknown://contents.txt')); } /** * @test */ public function guarding_against_valid_mount_identifiers(): void { $this->expectException(UnableToMountFilesystem::class); /* @phpstan-ignore-next-line */ new MountManager([1 => new Filesystem(new InMemoryFilesystemAdapter())]); } /** * @test */ public function guarding_against_mounting_invalid_filesystems(): void { $this->expectException(UnableToMountFilesystem::class); /* @phpstan-ignore-next-line */ new MountManager(['valid' => 'something else']); } /** * @test */ public function guarding_against_using_paths_without_mount_prefix(): void { $this->expectException(UnableToResolveFilesystemMount::class); $this->mountManager->read('path-without-mount-prefix.txt'); } /** * @test */ public function guard_against_using_unknown_mount(): void { $this->expectException(UnableToResolveFilesystemMount::class); $this->mountManager->read('unknown://location.txt'); } /** * @test */ public function generate_public_url(): void { $mountManager = new MountManager([ 'first' => new Filesystem($this->firstStubAdapter, ['public_url' => 'first.example.com']), 'second' => new Filesystem($this->secondStubAdapter, ['public_url' => 'second.example.com']), ]); $mountManager->write('first://file1.txt', 'content'); $mountManager->write('second://file2.txt', 'content'); $this->assertSame('first.example.com/file1.txt', $mountManager->publicUrl('first://file1.txt')); $this->assertSame('second.example.com/file2.txt', $mountManager->publicUrl('second://file2.txt')); } /** * @test */ public function provide_checksum(): void { $this->mountManager->write('first://file.txt', 'content'); $this->assertSame('9a0364b9e99bb480dd25e1f0284c8555', $this->mountManager->checksum('first://file.txt')); } } ================================================ FILE: src/PathNormalizer.php ================================================ prefix = rtrim($prefix, '\\/'); if ($this->prefix !== '' || $prefix === $separator) { $this->prefix .= $separator; } } public function prefixPath(string $path): string { return $this->prefix . ltrim($path, '\\/'); } public function stripPrefix(string $path): string { /* @var string */ return substr($path, strlen($this->prefix)); } public function stripDirectoryPrefix(string $path): string { return rtrim($this->stripPrefix($path), '\\/'); } public function prefixDirectoryPath(string $path): string { $prefixedPath = $this->prefixPath(rtrim($path, '\\/')); if ($prefixedPath === '' || substr($prefixedPath, -1) === $this->separator) { return $prefixedPath; } return $prefixedPath . $this->separator; } } ================================================ FILE: src/PathPrefixerTest.php ================================================ prefixPath('some/path.txt'); $this->assertEquals('prefix/some/path.txt', $prefixedPath); } /** * @test */ public function path_stripping_with_a_prefix(): void { $prefixer = new PathPrefixer('prefix'); $strippedPath = $prefixer->stripPrefix('prefix/some/path.txt'); $this->assertEquals('some/path.txt', $strippedPath); } /** * @test * * @dataProvider dpRootPaths */ public function an_absolute_root_path_is_supported(string $rootPath, string $separator, string $path, string $expectedPath): void { $prefixer = new PathPrefixer($rootPath, $separator); $prefixedPath = $prefixer->prefixPath($path); $this->assertEquals($expectedPath, $prefixedPath); } public static function dpRootPaths(): iterable { yield "unix-style root path" => ['/', '/', 'path.txt', '/path.txt']; yield "windows-style root path" => ['\\', '\\', 'path.txt', '\\path.txt']; } /** * @test */ public function path_stripping_is_reversable(): void { $prefixer = new PathPrefixer('prefix'); $strippedPath = $prefixer->stripPrefix('prefix/some/path.txt'); $this->assertEquals('prefix/some/path.txt', $prefixer->prefixPath($strippedPath)); $prefixedPath = $prefixer->prefixPath('some/path.txt'); $this->assertEquals('some/path.txt', $prefixer->stripPrefix($prefixedPath)); } /** * @test */ public function prefixing_without_a_prefix(): void { $prefixer = new PathPrefixer(''); $path = $prefixer->prefixPath('path/to/prefix.txt'); $this->assertEquals('path/to/prefix.txt', $path); $path = $prefixer->prefixPath('/path/to/prefix.txt'); $this->assertEquals('path/to/prefix.txt', $path); } /** * @test */ public function prefixing_for_a_directory(): void { $prefixer = new PathPrefixer('/prefix'); $path = $prefixer->prefixDirectoryPath('something'); $this->assertEquals('/prefix/something/', $path); $path = $prefixer->prefixDirectoryPath(''); $this->assertEquals('/prefix/', $path); } /** * @test */ public function prefixing_for_a_directory_without_a_prefix(): void { $prefixer = new PathPrefixer(''); $path = $prefixer->prefixDirectoryPath('something'); $this->assertEquals('something/', $path); $path = $prefixer->prefixDirectoryPath(''); $this->assertEquals('', $path); } /** * @test */ public function stripping_a_directory_prefix(): void { $prefixer = new PathPrefixer('/something/'); $path = $prefixer->stripDirectoryPrefix('/something/this/'); $this->assertEquals('this', $path); $path = $prefixer->stripDirectoryPrefix('/something/and-this\\'); $this->assertEquals('and-this', $path); } } ================================================ FILE: src/PathPrefixing/.gitattributes ================================================ * text=auto .github export-ignore .gitattributes export-ignore .gitignore export-ignore **/*Test.php export-ignore README.md export-ignore ================================================ FILE: src/PathPrefixing/.github/workflows/close-subsplit-prs.yaml ================================================ --- name: Close sub-split PRs on: push: branches: - 2.x - 3.x pull_request: branches: - 2.x - 3.x schedule: - cron: '30 7 * * *' jobs: close_subsplit_prs: runs-on: ubuntu-latest name: Close sub-split PRs steps: - uses: frankdejonge/action-close-subsplit-pr@0.1.0 with: close_pr: 'yes' target_branch_match: '^(?!master).+$' message: | Hi :wave:, Thank you for contributing to Flysystem. Unfortunately, you've sent a PR to a read-only sub-split repository. All pull requests should be directed towards: https://github.com/thephpleague/flysystem ================================================ FILE: src/PathPrefixing/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/PathPrefixing/PathPrefixedAdapter.php ================================================ prefix = new PathPrefixer($prefix); } public function read(string $location): string { try { return $this->adapter->read($this->prefix->prefixPath($location)); } catch (Throwable $previous) { throw UnableToReadFile::fromLocation($location, $previous->getMessage(), $previous); } } public function readStream(string $location) { try { return $this->adapter->readStream($this->prefix->prefixPath($location)); } catch (Throwable $previous) { throw UnableToReadFile::fromLocation($location, $previous->getMessage(), $previous); } } public function listContents(string $location, bool $deep): Generator { foreach ($this->adapter->listContents($this->prefix->prefixPath($location), $deep) as $attributes) { yield $attributes->withPath($this->prefix->stripPrefix($attributes->path())); } } public function fileExists(string $location): bool { try { return $this->adapter->fileExists($this->prefix->prefixPath($location)); } catch (Throwable $previous) { throw UnableToCheckFileExistence::forLocation($location, $previous); } } public function directoryExists(string $location): bool { try { return $this->adapter->directoryExists($this->prefix->prefixPath($location)); } catch (Throwable $previous) { throw UnableToCheckDirectoryExistence::forLocation($location, $previous); } } public function lastModified(string $path): FileAttributes { try { return $this->adapter->lastModified($this->prefix->prefixPath($path)); } catch (Throwable $previous) { throw UnableToRetrieveMetadata::lastModified($path, $previous->getMessage(), $previous); } } public function fileSize(string $path): FileAttributes { try { return $this->adapter->fileSize($this->prefix->prefixPath($path)); } catch (Throwable $previous) { throw UnableToRetrieveMetadata::fileSize($path, $previous->getMessage(), $previous); } } public function mimeType(string $path): FileAttributes { try { return $this->adapter->mimeType($this->prefix->prefixPath($path)); } catch (Throwable $previous) { throw UnableToRetrieveMetadata::mimeType($path, $previous->getMessage(), $previous); } } public function visibility(string $path): FileAttributes { try { return $this->adapter->visibility($this->prefix->prefixPath($path)); } catch (Throwable $previous) { throw UnableToRetrieveMetadata::visibility($path, $previous->getMessage(), $previous); } } public function write(string $location, string $contents, Config $config): void { try { $this->adapter->write($this->prefix->prefixPath($location), $contents, $config); } catch (Throwable $previous) { throw UnableToWriteFile::atLocation($location, $previous->getMessage(), $previous); } } public function writeStream(string $location, $contents, Config $config): void { try { $this->adapter->writeStream($this->prefix->prefixPath($location), $contents, $config); } catch (Throwable $previous) { throw UnableToWriteFile::atLocation($location, $previous->getMessage(), $previous); } } public function setVisibility(string $path, string $visibility): void { try { $this->adapter->setVisibility($this->prefix->prefixPath($path), $visibility); } catch (Throwable $previous) { throw UnableToSetVisibility::atLocation($path, $previous->getMessage(), $previous); } } public function delete(string $location): void { try { $this->adapter->delete($this->prefix->prefixPath($location)); } catch (Throwable $previous) { throw UnableToDeleteFile::atLocation($location, $previous->getMessage(), $previous); } } public function deleteDirectory(string $location): void { try { $this->adapter->deleteDirectory($this->prefix->prefixPath($location)); } catch (Throwable $previous) { throw UnableToDeleteDirectory::atLocation($location, $previous->getMessage(), $previous); } } public function createDirectory(string $location, Config $config): void { try { $this->adapter->createDirectory($this->prefix->prefixPath($location), $config); } catch (Throwable $previous) { throw UnableToCreateDirectory::atLocation($location, $previous->getMessage(), $previous); } } public function move(string $source, string $destination, Config $config): void { try { $this->adapter->move($this->prefix->prefixPath($source), $this->prefix->prefixPath($destination), $config); } catch (Throwable $previous) { throw UnableToMoveFile::fromLocationTo($source, $destination, $previous); } } public function copy(string $source, string $destination, Config $config): void { try { $this->adapter->copy($this->prefix->prefixPath($source), $this->prefix->prefixPath($destination), $config); } catch (Throwable $previous) { throw UnableToCopyFile::fromLocationTo($source, $destination, $previous); } } public function publicUrl(string $path, Config $config): string { if ( ! $this->adapter instanceof PublicUrlGenerator) { throw UnableToGeneratePublicUrl::noGeneratorConfigured($path); } return $this->adapter->publicUrl($this->prefix->prefixPath($path), $config); } public function checksum(string $path, Config $config): string { if ($this->adapter instanceof ChecksumProvider) { return $this->adapter->checksum($this->prefix->prefixPath($path), $config); } return $this->calculateChecksumFromStream($path, $config); } public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string { if ( ! $this->adapter instanceof TemporaryUrlGenerator) { throw UnableToGenerateTemporaryUrl::noGeneratorConfigured($path); } return $this->adapter->temporaryUrl($this->prefix->prefixPath($path), $expiresAt, $config); } } ================================================ FILE: src/PathPrefixing/PathPrefixedAdapterTest.php ================================================ write('foo.txt', 'bla', new Config); static::assertTrue($prefix->fileExists('foo.txt')); static::assertFalse($prefix->directoryExists('foo.txt')); static::assertTrue($adapter->fileExists('foo/foo.txt')); static::assertFalse($adapter->directoryExists('foo/foo.txt')); static::assertSame('bla', $prefix->read('foo.txt')); static::assertSame('bla', stream_get_contents($prefix->readStream('foo.txt'))); static::assertSame('text/plain', $prefix->mimeType('foo.txt')->mimeType()); static::assertSame(3, $prefix->fileSize('foo.txt')->fileSize()); static::assertSame(Visibility::PUBLIC, $prefix->visibility('foo.txt')->visibility()); $prefix->setVisibility('foo.txt', Visibility::PRIVATE); static::assertSame(Visibility::PRIVATE, $prefix->visibility('foo.txt')->visibility()); static::assertEqualsWithDelta($prefix->lastModified('foo.txt')->lastModified(), time(), 2); $prefix->copy('foo.txt', 'bla.txt', new Config); static::assertTrue($prefix->fileExists('bla.txt')); $prefix->createDirectory('dir', new Config()); static::assertTrue($prefix->directoryExists('dir')); static::assertFalse($prefix->directoryExists('dir2')); $prefix->deleteDirectory('dir'); static::assertFalse($prefix->directoryExists('dir')); $prefix->move('bla.txt', 'bla2.txt', new Config()); static::assertFalse($prefix->fileExists('bla.txt')); static::assertTrue($prefix->fileExists('bla2.txt')); $prefix->delete('bla2.txt'); static::assertFalse($prefix->fileExists('bla2.txt')); $prefix->createDirectory('test', new Config()); $files = iterator_to_array($prefix->listContents('', true)); static::assertCount(2, $files); } public function testWriteStream(): void { $adapter = new InMemoryFilesystemAdapter(); $prefix = new PathPrefixedAdapter($adapter, 'foo'); $tmpFile = sys_get_temp_dir() . '/' . uniqid('test', true); file_put_contents($tmpFile, 'test'); $prefix->writeStream('a.txt', fopen($tmpFile, 'rb'), new Config()); static::assertTrue($prefix->fileExists('a.txt')); static::assertSame('test', $prefix->read('a.txt')); static::assertSame('test', stream_get_contents($prefix->readStream('a.txt'))); unlink($tmpFile); } public function testEmptyPrefix(): void { static::expectException(\InvalidArgumentException::class); new PathPrefixedAdapter(new InMemoryFilesystemAdapter(), ''); } /** * @test */ public function generating_a_public_url(): void { $adapter = new class() extends InMemoryFilesystemAdapter implements PublicUrlGenerator { public function publicUrl(string $path, Config $config): string { return 'memory://' . ltrim($path, '/'); } }; $prefixedAdapter = new PathPrefixedAdapter($adapter, 'prefix'); $url = $prefixedAdapter->publicUrl('/path.txt', new Config()); self::assertEquals('memory://prefix/path.txt', $url); } /** * @test */ public function calculate_checksum_using_decorated_adapter(): void { $adapter = new class() extends InMemoryFilesystemAdapter implements ChecksumProvider { public function checksum(string $path, Config $config): string { return hash('md5', $this->read($path)); } }; $prefixedAdapter = new PathPrefixedAdapter($adapter, 'prefix'); $prefixedAdapter->write('foo.txt', 'bla', new Config); self::assertEquals('128ecf542a35ac5270a87dc740918404', $prefixedAdapter->checksum('foo.txt', new Config())); } /** * @test */ public function calculate_checksum_using_current_adapter(): void { $adapter = new InMemoryFilesystemAdapter(); $prefixedAdapter = new PathPrefixedAdapter($adapter, 'prefix'); $prefixedAdapter->write('foo.txt', 'bla', new Config); self::assertEquals('128ecf542a35ac5270a87dc740918404', hash('md5', 'bla')); self::assertEquals('128ecf542a35ac5270a87dc740918404', $prefixedAdapter->checksum('foo.txt', new Config())); } /** * @test */ public function failing_to_generate_a_public_url(): void { $prefixedAdapter = new PathPrefixedAdapter(new InMemoryFilesystemAdapter(), 'prefix'); $this->expectException(UnableToGeneratePublicUrl::class); $prefixedAdapter->publicUrl('/path.txt', new Config()); } } ================================================ FILE: src/PathPrefixing/README.md ================================================ ## Sub-split of Flysystem for path prefixed adapter decoration. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-path-prefixing ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/path-prefixing/). ================================================ FILE: src/PathPrefixing/composer.json ================================================ { "name": "league/flysystem-path-prefixing", "description": "Path prefixing filesystem adapter for Flysystem.", "keywords": ["flysystem", "filesystem", "prefixing", "prefix"], "type": "library", "prefer-stable": true, "autoload": { "psr-4": { "League\\Flysystem\\PathPrefixing\\": "" } }, "require": { "php": "^8.0.2", "league/flysystem": "^3.10.0" }, "license": "MIT", "authors": [ { "name": "Frank de Jonge", "email": "info@frankdejonge.nl" } ] } ================================================ FILE: src/PathTraversalDetected.php ================================================ path; } public static function forPath(string $path): PathTraversalDetected { $e = new PathTraversalDetected("Path traversal detected: {$path}"); $e->path = $path; return $e; } } ================================================ FILE: src/PhpseclibV2/.gitattributes ================================================ * text=auto .github export-ignore .gitattributes export-ignore .gitignore export-ignore **/*Test.php export-ignore **/*Stub.php export-ignore README.md export-ignore ================================================ FILE: src/PhpseclibV2/.github/workflows/close-subsplit-prs.yaml ================================================ --- name: Close sub-split PRs on: push: branches: - 2.x - 3.x pull_request: branches: - 2.x - 3.x schedule: - cron: '30 7 * * *' jobs: close_subsplit_prs: runs-on: ubuntu-latest name: Close sub-split PRs steps: - uses: frankdejonge/action-close-subsplit-pr@0.1.0 with: close_pr: 'yes' target_branch_match: '^(?!master).+$' message: | Hi :wave:, Thank you for contributing to Flysystem. Unfortunately, you've sent a PR to a read-only sub-split repository. All pull requests should be directed towards: https://github.com/thephpleague/flysystem ================================================ FILE: src/PhpseclibV2/ConnectionProvider.php ================================================ succeedAfter = $succeedAfter; } public function isConnected(SFTP $connection): bool { if ($this->numberOfTimesChecked >= $this->succeedAfter) { return true; } $this->numberOfTimesChecked++; return false; } } ================================================ FILE: src/PhpseclibV2/README.md ================================================ # CAUTION: This package is deprecated since Flysystem 3.0 Instead, use the [Flysystem for SFTP v3](https://github.com/thephpleague/flysystem-sftp-v3) ## Sub-split of Flysystem for SFTP using phpseclib2. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-sftp ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/sftp/). ================================================ FILE: src/PhpseclibV2/SftpAdapter.php ================================================ connectionProvider = $connectionProvider; $this->prefixer = new PathPrefixer($root); $this->visibilityConverter = $visibilityConverter ?? new PortableVisibilityConverter(); $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); } public function fileExists(string $path): bool { $location = $this->prefixer->prefixPath($path); try { return $this->connectionProvider->provideConnection()->is_file($location); } catch (Throwable $exception) { throw UnableToCheckFileExistence::forLocation($path, $exception); } } public function directoryExists(string $path): bool { $location = $this->prefixer->prefixDirectoryPath($path); try { return $this->connectionProvider->provideConnection()->is_dir($location); } catch (Throwable $exception) { throw UnableToCheckDirectoryExistence::forLocation($path, $exception); } } /** * @param string $path * @param string|resource $contents * @param Config $config * * @throws FilesystemException */ private function upload(string $path, $contents, Config $config): void { $this->ensureParentDirectoryExists($path, $config); $connection = $this->connectionProvider->provideConnection(); $location = $this->prefixer->prefixPath($path); if ( ! $connection->put($location, $contents, SFTP::SOURCE_STRING)) { throw UnableToWriteFile::atLocation($path, 'not able to write the file'); } if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { $this->setVisibility($path, $visibility); } } private function ensureParentDirectoryExists(string $path, Config $config): void { $parentDirectory = dirname($path); if ($parentDirectory === '' || $parentDirectory === '.') { return; } /** @var string $visibility */ $visibility = $config->get(Config::OPTION_DIRECTORY_VISIBILITY); $this->makeDirectory($parentDirectory, $visibility); } private function makeDirectory(string $directory, ?string $visibility): void { $location = $this->prefixer->prefixPath($directory); $connection = $this->connectionProvider->provideConnection(); if ($connection->is_dir($location)) { return; } $mode = $visibility ? $this->visibilityConverter->forDirectory( $visibility ) : $this->visibilityConverter->defaultForDirectories(); if ( ! $connection->mkdir($location, $mode, true) && ! $connection->is_dir($location)) { throw UnableToCreateDirectory::atLocation($directory); } } public function write(string $path, string $contents, Config $config): void { try { $this->upload($path, $contents, $config); } catch (UnableToWriteFile $exception) { throw $exception; } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } public function writeStream(string $path, $contents, Config $config): void { try { $this->upload($path, $contents, $config); } catch (UnableToWriteFile $exception) { throw $exception; } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } public function read(string $path): string { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); $contents = $connection->get($location); if ( ! is_string($contents)) { throw UnableToReadFile::fromLocation($path); } return $contents; } public function readStream(string $path) { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); /** @var resource $readStream */ $readStream = fopen('php://temp', 'w+'); if ( ! $connection->get($location, $readStream)) { fclose($readStream); throw UnableToReadFile::fromLocation($path); } rewind($readStream); return $readStream; } public function delete(string $path): void { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); $connection->delete($location); } public function deleteDirectory(string $path): void { $location = rtrim($this->prefixer->prefixPath($path), '/') . '/'; $connection = $this->connectionProvider->provideConnection(); $connection->delete($location); $connection->rmdir($location); } public function createDirectory(string $path, Config $config): void { $this->makeDirectory($path, $config->get(Config::OPTION_DIRECTORY_VISIBILITY, $config->get(Config::OPTION_VISIBILITY))); } public function setVisibility(string $path, string $visibility): void { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); $mode = $this->visibilityConverter->forFile($visibility); if ( ! $connection->chmod($mode, $location, false)) { throw UnableToSetVisibility::atLocation($path); } } private function fetchFileMetadata(string $path, string $type): FileAttributes { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); $stat = $connection->stat($location); if ( ! is_array($stat)) { throw UnableToRetrieveMetadata::create($path, $type); } $attributes = $this->convertListingToAttributes($path, $stat); if ( ! $attributes instanceof FileAttributes) { throw UnableToRetrieveMetadata::create($path, $type, 'path is not a file'); } return $attributes; } public function mimeType(string $path): FileAttributes { try { $mimetype = $this->detectMimeTypeUsingPath ? $this->mimeTypeDetector->detectMimeTypeFromPath($path) : $this->mimeTypeDetector->detectMimeType($path, $this->read($path)); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::mimeType($path, $exception->getMessage(), $exception); } if ($mimetype === null) { throw UnableToRetrieveMetadata::mimeType($path, 'Unknown.'); } return new FileAttributes($path, null, null, null, $mimetype); } public function lastModified(string $path): FileAttributes { return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_LAST_MODIFIED); } public function fileSize(string $path): FileAttributes { return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE); } public function visibility(string $path): FileAttributes { return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_VISIBILITY); } public function listContents(string $path, bool $deep): iterable { $connection = $this->connectionProvider->provideConnection(); $location = $this->prefixer->prefixPath(rtrim($path, '/')) . '/'; $listing = $connection->rawlist($location, false); if ($listing === false) { return; } foreach ($listing as $filename => $attributes) { if ($filename === '.' || $filename === '..') { continue; } // Ensure numeric keys are strings. $filename = (string) $filename; $path = $this->prefixer->stripPrefix($location . ltrim($filename, '/')); $attributes = $this->convertListingToAttributes($path, $attributes); yield $attributes; if ($deep && $attributes->isDir()) { foreach ($this->listContents($attributes->path(), true) as $child) { yield $child; } } } } private function convertListingToAttributes(string $path, array $attributes): StorageAttributes { $permissions = $attributes['permissions'] & 0777; $lastModified = $attributes['mtime'] ?? null; if (($attributes['type'] ?? null) === NET_SFTP_TYPE_DIRECTORY) { return new DirectoryAttributes( ltrim($path, '/'), $this->visibilityConverter->inverseForDirectory($permissions), $lastModified ); } return new FileAttributes( $path, $attributes['size'], $this->visibilityConverter->inverseForFile($permissions), $lastModified ); } public function move(string $source, string $destination, Config $config): void { $sourceLocation = $this->prefixer->prefixPath($source); $destinationLocation = $this->prefixer->prefixPath($destination); $connection = $this->connectionProvider->provideConnection(); try { $this->ensureParentDirectoryExists($destination, $config); } catch (Throwable $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } if ( ! $connection->rename($sourceLocation, $destinationLocation)) { throw UnableToMoveFile::fromLocationTo($source, $destination); } } public function copy(string $source, string $destination, Config $config): void { try { $readStream = $this->readStream($source); $visibility = $this->visibility($source)->visibility(); $this->writeStream($destination, $readStream, new Config(compact(Config::OPTION_VISIBILITY))); } catch (Throwable $exception) { if (isset($readStream) && is_resource($readStream)) { @fclose($readStream); } throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } } ================================================ FILE: src/PhpseclibV2/SftpAdapterTest.php ================================================ provideConnection(); $this->connection = $connection; $this->connection->reset(); } /** * @test */ public function failing_to_create_a_directory(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToCreateDirectory::class); $adapter->createDirectory('not-gonna-happen', new Config()); } /** * @test */ public function failing_to_write_a_file(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToWriteFile::class); $adapter->write('not-gonna-happen', 'na-ah', new Config()); } /** * @test */ public function failing_to_read_a_file(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToReadFile::class); $adapter->read('not-gonna-happen'); } /** * @test */ public function failing_to_read_a_file_as_a_stream(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToReadFile::class); $adapter->readStream('not-gonna-happen'); } /** * @test */ public function failing_to_write_a_file_using_streams(): void { $adapter = $this->adapterWithInvalidRoot(); $writeHandle = stream_with_contents('contents'); $this->expectException(UnableToWriteFile::class); try { $adapter->writeStream('not-gonna-happen', $writeHandle, new Config()); } finally { fclose($writeHandle); } } /** * @test */ public function detecting_mimetype(): void { $adapter = $this->adapter(); $adapter->write('file.svg', (string) file_get_contents(__DIR__ . '/../AdapterTestUtilities/test_files/flysystem.svg'), new Config()); $mimeType = $adapter->mimeType('file.svg'); $this->assertStringStartsWith('image/svg+xml', $mimeType->mimeType()); } /** * @test */ public function failing_to_chmod_when_writing(): void { $this->connection->failOnChmod('/upload/path.txt'); $adapter = $this->adapter(); $this->expectException(UnableToWriteFile::class); $adapter->write('path.txt', 'contents', new Config(['visibility' => 'public'])); } /** * @test */ public function failing_to_move_a_file_cause_the_parent_directory_cant_be_created(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToMoveFile::class); $adapter->move('path.txt', 'new-path.txt', new Config()); } /** * @test */ public function failing_to_copy_a_file(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToCopyFile::class); $adapter->copy('path.txt', 'new-path.txt', new Config()); } /** * @test */ public function failing_to_copy_a_file_because_writing_fails(): void { $this->givenWeHaveAnExistingFile('path.txt', 'contents'); $adapter = $this->adapter(); $this->connection->failOnPut('/upload/new-path.txt'); $this->expectException(UnableToCopyFile::class); $adapter->copy('path.txt', 'new-path.txt', new Config()); } /** * @test */ public function failing_to_chmod_when_writing_with_a_stream(): void { $writeStream = stream_with_contents('contents'); $this->connection->failOnChmod('/upload/path.txt'); $adapter = $this->adapter(); $this->expectException(UnableToWriteFile::class); try { $adapter->writeStream('path.txt', $writeStream, new Config(['visibility' => 'public'])); } finally { @fclose($writeStream); } } /** * @test */ public function list_contents_directory_does_not_exist(): void { $contents = $this->adapter()->listContents('/does_not_exist', false); $this->assertCount(0, iterator_to_array($contents)); } private static function connectionProvider(): ConnectionProvider { if ( ! static::$connectionProvider instanceof ConnectionProvider) { static::$connectionProvider = new StubSftpConnectionProvider('localhost', 'foo', 'pass', 2222); } return static::$connectionProvider; } /** * @return SftpAdapter */ private function adapterWithInvalidRoot(): SftpAdapter { $provider = static::connectionProvider(); $adapter = new SftpAdapter($provider, '/invalid'); return $adapter; } } ================================================ FILE: src/PhpseclibV2/SftpConnectionProvider.php ================================================ host = $host; $this->username = $username; $this->password = $password; $this->privateKey = $privateKey; $this->passphrase = $passphrase; $this->useAgent = $useAgent; $this->port = $port; $this->timeout = $timeout; $this->hostFingerprint = $hostFingerprint; $this->connectivityChecker = $connectivityChecker ?? new SimpleConnectivityChecker(); $this->maxTries = $maxTries; } public function provideConnection(): SFTP { $tries = 0; start: $connection = $this->connection instanceof SFTP ? $this->connection : $this->setupConnection(); if ( ! $this->connectivityChecker->isConnected($connection)) { $connection->disconnect(); $this->connection = null; if ($tries < $this->maxTries) { $tries++; goto start; } throw UnableToConnectToSftpHost::atHostname($this->host); } return $this->connection = $connection; } private function setupConnection(): SFTP { $connection = new SFTP($this->host, $this->port, $this->timeout); $this->disableStatCache && $connection->disableStatCache(); try { $this->checkFingerprint($connection); $this->authenticate($connection); } catch (Throwable $exception) { $connection->disconnect(); throw $exception; } return $connection; } private function checkFingerprint(SFTP $connection): void { if ( ! $this->hostFingerprint) { return; } $publicKey = $connection->getServerPublicHostKey(); if ($publicKey === false) { throw UnableToEstablishAuthenticityOfHost::becauseTheAuthenticityCantBeEstablished($this->host); } $fingerprint = $this->getFingerprintFromPublicKey($publicKey); if (0 !== strcasecmp($this->hostFingerprint, $fingerprint)) { throw UnableToEstablishAuthenticityOfHost::becauseTheAuthenticityCantBeEstablished($this->host); } } private function getFingerprintFromPublicKey(string $publicKey): string { $content = explode(' ', $publicKey, 3); return implode(':', str_split(md5(base64_decode($content[1])), 2)); } private function authenticate(SFTP $connection): void { if ($this->privateKey !== null) { $this->authenticateWithPrivateKey($connection); } elseif ($this->useAgent) { $this->authenticateWithAgent($connection); } elseif ( ! $connection->login($this->username, $this->password)) { throw UnableToAuthenticate::withPassword(); } } public static function fromArray(array $options): SftpConnectionProvider { return new SftpConnectionProvider( $options['host'], $options['username'], $options['password'] ?? null, $options['privateKey'] ?? null, $options['passphrase'] ?? null, $options['port'] ?? 22, $options['useAgent'] ?? false, $options['timeout'] ?? 10, $options['maxTries'] ?? 4, $options['hostFingerprint'] ?? null, $options['connectivityChecker'] ?? null ); } private function authenticateWithPrivateKey(SFTP $connection): void { $privateKey = $this->loadPrivateKey(); if ($connection->login($this->username, $privateKey)) { return; } if ($this->password !== null && $connection->login($this->username, $this->password)) { return; } throw UnableToAuthenticate::withPrivateKey(); } private function loadPrivateKey(): RSA { if ("---" !== substr($this->privateKey, 0, 3) && is_file($this->privateKey)) { $this->privateKey = file_get_contents($this->privateKey); } $key = new RSA(); if ($this->passphrase !== null) { $key->setPassword($this->passphrase); } if ( ! $key->loadKey($this->privateKey)) { throw new UnableToLoadPrivateKey(); } return $key; } private function authenticateWithAgent(SFTP $connection): void { $agent = new Agent(); if ( ! $connection->login($this->username, $agent)) { throw UnableToAuthenticate::withSshAgent(); } } } ================================================ FILE: src/PhpseclibV2/SftpConnectionProviderTest.php ================================================ expectException(UnableToConnectToSftpHost::class); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, 'timeout' => 10, 'connectivityChecker' => new FixatedConnectivityChecker(5) ] ); $provider->provideConnection(); } /** * @test */ public function trying_until_5_tries(): void { $provider = SftpConnectionProvider::fromArray([ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, 'timeout' => 10, 'connectivityChecker' => new FixatedConnectivityChecker(4) ]); $connection = $provider->provideConnection(); $sameConnection = $provider->provideConnection(); $this->assertInstanceOf(SFTP::class, $connection); $this->assertSame($connection, $sameConnection); } /** * @test */ public function authenticating_with_a_private_key(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'bar', 'privateKey' => __DIR__ . '/../../test_files/sftp/id_rsa', 'passphrase' => 'secret', 'port' => 2222, ] ); $connection = $provider->provideConnection(); $this->assertInstanceOf(SFTP::class, $connection); } /** * @test */ public function authenticating_with_an_invalid_private_key(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'bar', 'privateKey' => __DIR__ . '/../../test_files/sftp/users.conf', 'port' => 2222, ] ); $this->expectException(UnableToLoadPrivateKey::class); $provider->provideConnection(); } /** * @test */ public function authenticating_with_an_ssh_agent(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'bar', 'useAgent' => true, 'port' => 2222, ] ); $connection = $provider->provideConnection(); $this->assertInstanceOf(SFTP::class, $connection); } /** * @test */ public function failing_to_authenticating_with_an_ssh_agent(): void { $this->expectException(UnableToAuthenticate::class); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'useAgent' => true, 'port' => 2222, ] ); $provider->provideConnection(); } /** * @test */ public function authenticating_with_a_private_key_and_falling_back_to_password(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'privateKey' => __DIR__ . '/../../test_files/sftp/id_rsa', 'passphrase' => 'secret', 'port' => 2222, ] ); $connection = $provider->provideConnection(); $this->assertInstanceOf(SFTP::class, $connection); } /** * @test */ public function not_being_able_to_authenticate_with_a_private_key(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'privateKey' => __DIR__ . '/../../test_files/sftp/unknown.key', 'passphrase' => 'secret', 'port' => 2222, ] ); $this->expectExceptionObject(UnableToAuthenticate::withPrivateKey()); $provider->provideConnection(); } /** * @test */ public function verifying_a_fingerprint(): void { $key = file_get_contents(__DIR__ . '/../../test_files/sftp/ssh_host_rsa_key.pub'); $fingerPrint = $this->computeFingerPrint($key); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, 'hostFingerprint' => $fingerPrint, ] ); $anotherConnection = $provider->provideConnection(); $this->assertInstanceOf(SFTP::class, $anotherConnection); } /** * @test */ public function providing_an_invalid_fingerprint(): void { $this->expectException(UnableToEstablishAuthenticityOfHost::class); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, 'hostFingerprint' => 'invalid:fingerprint', ] ); $provider->provideConnection(); } /** * @test */ public function providing_an_invalid_password(): void { $this->expectException(UnableToAuthenticate::class); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'lol', 'port' => 2222, ] ); $provider->provideConnection(); } private function computeFingerPrint(string $publicKey): string { $content = explode(' ', $publicKey, 3); return implode(':', str_split(md5(base64_decode($content[1])), 2)); } } ================================================ FILE: src/PhpseclibV2/SftpStub.php ================================================ */ private $tripWires = []; public function failOnChmod(string $filename): void { $key = $this->formatTripKey('chmod', $filename); $this->tripWires[$key] = true; } /** * @param int $mode * @param string $filename * @param bool $recursive * * @return bool|mixed */ public function chmod($mode, $filename, $recursive = false) { $key = $this->formatTripKey('chmod', $filename); $shouldTrip = $this->tripWires[$key] ?? false; if ($shouldTrip) { unset($this->tripWires[$key]); return false; } return parent::chmod($mode, $filename, $recursive); } public function failOnPut(string $filename): void { $key = $this->formatTripKey('put', $filename); $this->tripWires[$key] = true; } /** * @param string $remote_file * @param resource|string $data * @param int $mode * @param int $start * @param int $local_start * @param null $progressCallback * * @return bool */ public function put( $remote_file, $data, $mode = self::SOURCE_STRING, $start = -1, $local_start = -1, $progressCallback = null ) { $key = $this->formatTripKey('put', $remote_file); $shouldTrip = $this->tripWires[$key] ?? false; if ($shouldTrip) { return false; } return parent::put($remote_file, $data, $mode, $start, $local_start, $progressCallback); } /** * @param array $arguments * * @return string */ private function formatTripKey(...$arguments): string { $key = ''; foreach ($arguments as $argument) { $key .= var_export($argument, true); } return $key; } public function reset(): void { $this->tripWires = []; } } ================================================ FILE: src/PhpseclibV2/SimpleConnectivityChecker.php ================================================ isConnected(); } } ================================================ FILE: src/PhpseclibV2/StubSftpConnectionProvider.php ================================================ host = $host; $this->username = $username; $this->password = $password; $this->port = $port; } public function provideConnection(): SFTP { if ( ! $this->connection instanceof SFTP) { $connection = new SftpStub($this->host, $this->port); $connection->login($this->username, $this->password); $this->connection = $connection; } return $this->connection; } } ================================================ FILE: src/PhpseclibV2/UnableToAuthenticate.php ================================================ succeedAfter = $succeedAfter; } public function isConnected(SFTP $connection): bool { if ($this->numberOfTimesChecked >= $this->succeedAfter) { return true; } $this->numberOfTimesChecked++; return false; } } ================================================ FILE: src/PhpseclibV3/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/PhpseclibV3/README.md ================================================ ## Sub-split of Flysystem for SFTP using phpseclib v3. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-sftp-v3 ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/sftp-v3/). ================================================ FILE: src/PhpseclibV3/SftpAdapter.php ================================================ prefixer = new PathPrefixer($root); $this->visibilityConverter = $visibilityConverter ?? new PortableVisibilityConverter(); $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); } public function fileExists(string $path): bool { $location = $this->prefixer->prefixPath($path); try { return $this->connectionProvider->provideConnection()->is_file($location); } catch (Throwable $exception) { throw UnableToCheckFileExistence::forLocation($path, $exception); } } public function disconnect(): void { $this->connectionProvider->disconnect(); } public function directoryExists(string $path): bool { $location = $this->prefixer->prefixDirectoryPath($path); try { return $this->connectionProvider->provideConnection()->is_dir($location); } catch (Throwable $exception) { throw UnableToCheckDirectoryExistence::forLocation($path, $exception); } } /** * @param string $path * @param string|resource $contents * @param Config $config * * @throws FilesystemException */ private function upload(string $path, $contents, Config $config): void { $this->ensureParentDirectoryExists($path, $config); $connection = $this->connectionProvider->provideConnection(); $location = $this->prefixer->prefixPath($path); if ( ! $connection->put($location, $contents, SFTP::SOURCE_STRING)) { throw UnableToWriteFile::atLocation($path, 'not able to write the file'); } if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { $this->setVisibility($path, $visibility); } } private function ensureParentDirectoryExists(string $path, Config $config): void { $parentDirectory = dirname($path); if ($parentDirectory === '' || $parentDirectory === '.') { return; } /** @var string $visibility */ $visibility = $config->get(Config::OPTION_DIRECTORY_VISIBILITY); $this->makeDirectory($parentDirectory, $visibility); } private function makeDirectory(string $directory, ?string $visibility): void { $location = $this->prefixer->prefixPath($directory); $connection = $this->connectionProvider->provideConnection(); if ($connection->is_dir($location)) { return; } $mode = $visibility ? $this->visibilityConverter->forDirectory( $visibility ) : $this->visibilityConverter->defaultForDirectories(); if ( ! $connection->mkdir($location, $mode, true) && ! $connection->is_dir($location)) { throw UnableToCreateDirectory::atLocation($directory); } } public function write(string $path, string $contents, Config $config): void { try { $this->upload($path, $contents, $config); } catch (UnableToWriteFile $exception) { throw $exception; } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } public function writeStream(string $path, $contents, Config $config): void { try { $this->upload($path, $contents, $config); } catch (UnableToWriteFile $exception) { throw $exception; } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } public function read(string $path): string { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); $contents = $connection->get($location); if ( ! is_string($contents)) { throw UnableToReadFile::fromLocation($path); } return $contents; } public function readStream(string $path) { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); /** @var resource $readStream */ $readStream = fopen('php://temp', 'w+'); if ( ! $connection->get($location, $readStream)) { fclose($readStream); throw UnableToReadFile::fromLocation($path); } rewind($readStream); return $readStream; } public function delete(string $path): void { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); $connection->delete($location); } public function deleteDirectory(string $path): void { $location = rtrim($this->prefixer->prefixPath($path), '/') . '/'; $connection = $this->connectionProvider->provideConnection(); $connection->delete($location); $connection->rmdir($location); } public function createDirectory(string $path, Config $config): void { $this->makeDirectory($path, $config->get(Config::OPTION_DIRECTORY_VISIBILITY, $config->get(Config::OPTION_VISIBILITY))); } public function setVisibility(string $path, string $visibility): void { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); $mode = $this->visibilityConverter->forFile($visibility); if ( ! $connection->chmod($mode, $location, false)) { throw UnableToSetVisibility::atLocation($path); } } private function fetchFileMetadata(string $path, string $type): FileAttributes { $location = $this->prefixer->prefixPath($path); $connection = $this->connectionProvider->provideConnection(); $stat = $connection->stat($location); if ( ! is_array($stat)) { throw UnableToRetrieveMetadata::create($path, $type); } $attributes = $this->convertListingToAttributes($path, $stat); if ( ! $attributes instanceof FileAttributes) { throw UnableToRetrieveMetadata::create($path, $type, 'path is not a file'); } return $attributes; } public function mimeType(string $path): FileAttributes { try { $mimetype = $this->detectMimeTypeUsingPath ? $this->mimeTypeDetector->detectMimeTypeFromPath($path) : $this->mimeTypeDetector->detectMimeType($path, $this->read($path)); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::mimeType($path, $exception->getMessage(), $exception); } if ($mimetype === null) { throw UnableToRetrieveMetadata::mimeType($path, 'Unknown.'); } return new FileAttributes($path, null, null, null, $mimetype); } public function lastModified(string $path): FileAttributes { return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_LAST_MODIFIED); } public function fileSize(string $path): FileAttributes { return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_FILE_SIZE); } public function visibility(string $path): FileAttributes { return $this->fetchFileMetadata($path, FileAttributes::ATTRIBUTE_VISIBILITY); } public function listContents(string $path, bool $deep): iterable { $connection = $this->connectionProvider->provideConnection(); $location = $this->prefixer->prefixPath(rtrim($path, '/')) . '/'; $listing = $connection->rawlist($location, false); if (false === $listing) { return; } foreach ($listing as $filename => $attributes) { if ($filename === '.' || $filename === '..') { continue; } // Ensure numeric keys are strings. $filename = (string) $filename; $path = $this->prefixer->stripPrefix($location . ltrim($filename, '/')); $attributes = $this->convertListingToAttributes($path, $attributes); yield $attributes; if ($deep && $attributes->isDir()) { foreach ($this->listContents($attributes->path(), true) as $child) { yield $child; } } } } private function convertListingToAttributes(string $path, array $attributes): StorageAttributes { $permissions = $attributes['mode'] & 0777; $lastModified = $attributes['mtime'] ?? null; if (($attributes['type'] ?? null) === NET_SFTP_TYPE_DIRECTORY) { return new DirectoryAttributes( ltrim($path, '/'), $this->visibilityConverter->inverseForDirectory($permissions), $lastModified ); } return new FileAttributes( $path, $attributes['size'], $this->visibilityConverter->inverseForFile($permissions), $lastModified ); } public function move(string $source, string $destination, Config $config): void { $sourceLocation = $this->prefixer->prefixPath($source); $destinationLocation = $this->prefixer->prefixPath($destination); $connection = $this->connectionProvider->provideConnection(); try { $this->ensureParentDirectoryExists($destination, $config); } catch (Throwable $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } if ($sourceLocation === $destinationLocation) { return; } if ($connection->rename($sourceLocation, $destinationLocation)) { return; } // Overwrite existing file / dir if ($connection->is_file($destinationLocation)) { $this->delete($destination); if ($connection->rename($sourceLocation, $destinationLocation)) { return; } } throw UnableToMoveFile::fromLocationTo($source, $destination); } public function copy(string $source, string $destination, Config $config): void { try { $readStream = $this->readStream($source); $visibility = $config->get(Config::OPTION_VISIBILITY); if ($visibility === null && $config->get(Config::OPTION_RETAIN_VISIBILITY, true)) { $config = $config->withSetting(Config::OPTION_VISIBILITY, $this->visibility($source)->visibility()); } $this->writeStream($destination, $readStream, $config); } catch (Throwable $exception) { if (isset($readStream) && is_resource($readStream)) { @fclose($readStream); } throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } public function __destruct() { if ($this->disconnectOnDestruct) { $this->connectionProvider->disconnect(); } } } ================================================ FILE: src/PhpseclibV3/SftpAdapterTest.php ================================================ provideConnection(); $this->connection = $connection; $this->connection->resetTripWires(); } /** * @test */ public function failing_to_create_a_directory(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToCreateDirectory::class); $adapter->createDirectory('not-gonna-happen', new Config()); } /** * @test */ public function failing_to_write_a_file(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToWriteFile::class); $adapter->write('not-gonna-happen', 'na-ah', new Config()); } /** * @test */ public function failing_to_read_a_file(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToReadFile::class); $adapter->read('not-gonna-happen'); } /** * @test */ public function failing_to_read_a_file_as_a_stream(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToReadFile::class); $adapter->readStream('not-gonna-happen'); } /** * @test */ public function failing_to_write_a_file_using_streams(): void { $adapter = $this->adapterWithInvalidRoot(); $writeHandle = stream_with_contents('contents'); $this->expectException(UnableToWriteFile::class); try { $adapter->writeStream('not-gonna-happen', $writeHandle, new Config()); } finally { fclose($writeHandle); } } /** * @test */ public function detecting_mimetype(): void { $adapter = $this->adapter(); $adapter->write('file.svg', (string) file_get_contents(__DIR__ . '/../AdapterTestUtilities/test_files/flysystem.svg'), new Config()); $mimeType = $adapter->mimeType('file.svg'); $this->assertStringStartsWith('image/svg+xml', $mimeType->mimeType()); } /** * @test */ public function failing_to_chmod_when_writing(): void { $this->connection->failOnChmod('/upload/path.txt'); $adapter = $this->adapter(); $this->expectException(UnableToWriteFile::class); $adapter->write('path.txt', 'contents', new Config(['visibility' => 'public'])); } /** * @test */ public function failing_to_move_a_file_cause_the_parent_directory_cant_be_created(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToMoveFile::class); $adapter->move('path.txt', 'new-path.txt', new Config()); } /** * @test */ public function failing_to_copy_a_file(): void { $adapter = $this->adapterWithInvalidRoot(); $this->expectException(UnableToCopyFile::class); $adapter->copy('path.txt', 'new-path.txt', new Config()); } /** * @test */ public function failing_to_copy_a_file_because_writing_fails(): void { $this->givenWeHaveAnExistingFile('path.txt', 'contents'); $adapter = $this->adapter(); $this->connection->failOnPut('/upload/new-path.txt'); $this->expectException(UnableToCopyFile::class); $adapter->copy('path.txt', 'new-path.txt', new Config()); } /** * @test */ public function failing_to_chmod_when_writing_with_a_stream(): void { $writeStream = stream_with_contents('contents'); $this->connection->failOnChmod('/upload/path.txt'); $adapter = $this->adapter(); $this->expectException(UnableToWriteFile::class); try { $adapter->writeStream('path.txt', $writeStream, new Config(['visibility' => 'public'])); } finally { @fclose($writeStream); } } /** * @test */ public function list_contents_directory_does_not_exist(): void { $contents = $this->adapter()->listContents('/does_not_exist', false); $this->assertCount(0, iterator_to_array($contents)); } /** * @test */ public function it_can_proactively_close_a_connection(): void { /** @var SftpAdapter $adapter */ $adapter = $this->adapter(); self::assertFalse($adapter->fileExists('does not exists at all')); self::assertTrue(static::$connectionProvider->connection->isConnected()); $adapter->disconnect(); self::assertFalse(static::$connectionProvider->connection->isConnected()); } /** * @test * @fixme Move to FilesystemAdapterTestCase once all adapters pass */ public function moving_a_file_and_overwriting(): void { $this->runScenario(function() { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be moved', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->write( 'destination.txt', 'contents to be overwritten', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->move('source.txt', 'destination.txt', new Config()); $this->assertFalse( $adapter->fileExists('source.txt'), 'After moving a file should no longer exist in the original location.' ); $this->assertTrue( $adapter->fileExists('destination.txt'), 'After moving, a file should be present at the new location.' ); $this->assertEquals(Visibility::PUBLIC, $adapter->visibility('destination.txt')->visibility()); $this->assertEquals('contents to be moved', $adapter->read('destination.txt')); }); } private static function connectionProvider(): StubSftpConnectionProvider { if ( ! static::$connectionProvider instanceof ConnectionProvider) { static::$connectionProvider = new StubSftpConnectionProvider('localhost', 'foo', 'pass', 2222); } return static::$connectionProvider; } /** * @return SftpAdapter */ private function adapterWithInvalidRoot(): SftpAdapter { $provider = static::connectionProvider(); return new SftpAdapter($provider, '/invalid'); } } ================================================ FILE: src/PhpseclibV3/SftpConnectionProvider.php ================================================ connectivityChecker = $connectivityChecker ?? new SimpleConnectivityChecker(); } public function provideConnection(): SFTP { $tries = 0; start: $tries++; try { $connection = $this->connection instanceof SFTP ? $this->connection : $this->setupConnection(); } catch (Throwable $exception) { if ($tries <= $this->maxTries) { goto start; } if ($exception instanceof FilesystemException) { throw $exception; } throw UnableToConnectToSftpHost::atHostname($this->host, $exception); } if ( ! $this->connectivityChecker->isConnected($connection)) { $connection->disconnect(); $this->connection = null; if ($tries <= $this->maxTries) { goto start; } throw UnableToConnectToSftpHost::atHostname($this->host); } return $this->connection = $connection; } public function disconnect(): void { if ($this->connection) { $this->connection->disconnect(); $this->connection = null; } } private function setupConnection(): SFTP { $connection = new SFTP($this->host, $this->port, $this->timeout); $connection->setPreferredAlgorithms($this->preferredAlgorithms); $this->disableStatCache && $connection->disableStatCache(); try { $this->checkFingerprint($connection); $this->authenticate($connection); } catch (Throwable $exception) { $connection->disconnect(); throw $exception; } return $connection; } private function checkFingerprint(SFTP $connection): void { if ( ! $this->hostFingerprint) { return; } $publicKey = $connection->getServerPublicHostKey(); if ($publicKey === false) { throw UnableToEstablishAuthenticityOfHost::becauseTheAuthenticityCantBeEstablished($this->host); } $fingerprint = $this->getFingerprintFromPublicKey($publicKey); if (0 !== strcasecmp($this->hostFingerprint, $fingerprint)) { throw UnableToEstablishAuthenticityOfHost::becauseTheAuthenticityCantBeEstablished($this->host); } } private function getFingerprintFromPublicKey(string $publicKey): string { $content = explode(' ', $publicKey, 3); $algo = $content[0] === 'ssh-rsa' ? 'md5' : 'sha512'; return implode(':', str_split(hash($algo, base64_decode($content[1])), 2)); } private function authenticate(SFTP $connection): void { if ($this->privateKey !== null) { $this->authenticateWithPrivateKey($connection); } elseif ($this->useAgent) { $this->authenticateWithAgent($connection); } else { $this->authenticateWithUsernameAndPassword($connection); } } private function authenticateWithUsernameAndPassword(SFTP $connection): void { if ( ! $connection->login($this->username, $this->password)) { throw UnableToAuthenticate::withPassword($connection->getLastError()); } } public static function fromArray(array $options): SftpConnectionProvider { return new SftpConnectionProvider( $options['host'], $options['username'], $options['password'] ?? null, $options['privateKey'] ?? null, $options['passphrase'] ?? null, $options['port'] ?? 22, $options['useAgent'] ?? false, $options['timeout'] ?? 10, $options['maxTries'] ?? 4, $options['hostFingerprint'] ?? null, $options['connectivityChecker'] ?? null, $options['preferredAlgorithms'] ?? [], ); } private function authenticateWithPrivateKey(SFTP $connection): void { $privateKey = $this->loadPrivateKey(); if ($connection->login($this->username, $privateKey)) { return; } if ($this->password !== null && $connection->login($this->username, $this->password)) { return; } throw UnableToAuthenticate::withPrivateKey($connection->getLastError()); } private function loadPrivateKey(): AsymmetricKey { if (("---" !== substr($this->privateKey, 0, 3) || "PuTTY" !== substr($this->privateKey, 0, 5)) && is_file($this->privateKey)) { $this->privateKey = file_get_contents($this->privateKey); } try { if ($this->passphrase !== null) { return PublicKeyLoader::load($this->privateKey, $this->passphrase); } return PublicKeyLoader::load($this->privateKey); } catch (NoKeyLoadedException $exception) { throw new UnableToLoadPrivateKey(null, $exception); } } private function authenticateWithAgent(SFTP $connection): void { $agent = new Agent(); if ( ! $connection->login($this->username, $agent)) { throw UnableToAuthenticate::withSshAgent($connection->getLastError()); } } } ================================================ FILE: src/PhpseclibV3/SftpConnectionProviderTest.php ================================================ expectException(UnableToConnectToSftpHost::class); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, 'timeout' => 10, 'connectivityChecker' => new FixatedConnectivityChecker(5), ] ); $provider->provideConnection(); } /** * @test */ public function trying_until_5_tries(): void { $provider = SftpConnectionProvider::fromArray([ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, 'timeout' => 10, 'connectivityChecker' => new FixatedConnectivityChecker(4), ]); $connection = $provider->provideConnection(); $sameConnection = $provider->provideConnection(); $this->assertInstanceOf(SFTP::class, $connection); $this->assertSame($connection, $sameConnection); } /** * @test */ public function authenticating_with_a_private_key(): void { $provider = SftpConnectionProvider::fromArray([ 'host' => 'localhost', 'username' => 'bar', 'privateKey' => __DIR__ . '/../../test_files/sftp/id_rsa', 'passphrase' => 'secret', 'port' => 2222, ]); $connection = null; $this->runWithRetries(function () use (&$connection, $provider) { $connection = $provider->provideConnection(); }); $this->assertInstanceOf(SFTP::class, $connection); } /** * @test */ public function authenticating_with_an_invalid_private_key(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'bar', 'privateKey' => __DIR__ . '/../../test_files/sftp/users.conf', 'port' => 2222, ] ); $this->expectException(UnableToLoadPrivateKey::class); $this->runWithRetries(fn () => $provider->provideConnection(), UnableToLoadPrivateKey::class); } /** * @test */ public function authenticating_with_an_ssh_agent(): void { if (getenv('COMPOSER_OPTS') === false) { $this->markTestSkipped('Test is not run locally'); } $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'bar', 'useAgent' => true, 'port' => 2222, ] ); $connection = null; $this->runWithRetries(function () use ($provider, &$connection) { $connection = $provider->provideConnection(); }); $this->assertInstanceOf(SFTP::class, $connection); } /** * @test */ public function failing_to_authenticating_with_an_ssh_agent(): void { $this->expectException(UnableToAuthenticate::class); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'useAgent' => true, 'port' => 2222, ] ); $provider->provideConnection(); } /** * @test */ public function authenticating_with_a_private_key_and_falling_back_to_password(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'privateKey' => __DIR__ . '/../../test_files/sftp/id_rsa', 'passphrase' => 'secret', 'port' => 2222, ] ); $connection = null; $this->runWithRetries(function () use ($provider, &$connection) { $connection = $provider->provideConnection(); }); $this->assertInstanceOf(SFTP::class, $connection); } /** * @test */ public function not_being_able_to_authenticate_with_a_private_key(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'privateKey' => __DIR__ . '/../../test_files/sftp/unknown.key', 'passphrase' => 'secret', 'port' => 2222, ] ); $this->expectExceptionObject(UnableToAuthenticate::withPrivateKey()); $this->runWithRetries(fn () => $provider->provideConnection(), UnableToAuthenticate::class); } /** * @test */ public function verifying_a_fingerprint(): void { $key = file_get_contents(__DIR__ . '/../../test_files/sftp/ssh_host_ed25519_key.pub'); $fingerPrint = $this->computeFingerPrint($key); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, 'hostFingerprint' => $fingerPrint, ] ); $connection = null; $this->runWithRetries(function () use ($provider, &$connection) { $connection = $provider->provideConnection(); }); $this->assertInstanceOf(SFTP::class, $connection); } /** * @test */ public function providing_an_invalid_fingerprint(): void { $this->expectException(UnableToEstablishAuthenticityOfHost::class); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, 'hostFingerprint' => 'invalid:fingerprint', ] ); $this->runWithRetries(fn () => $provider->provideConnection(), UnableToEstablishAuthenticityOfHost::class); } /** * @test */ public function providing_an_invalid_password(): void { $this->expectException(UnableToAuthenticate::class); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'lol', 'port' => 2222, ] ); $this->runWithRetries(fn () => $provider->provideConnection(), UnableToAuthenticate::class); } /** * @test */ public function retries_several_times_until_failure(): void { $connectivityChecker = new class implements ConnectivityChecker { /** @var int */ public $calls = 0; public function isConnected(SFTP $connection): bool { ++$this->calls; return false; } }; $maxTries = 2; $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'bar', 'privateKey' => __DIR__ . '/../../test_files/sftp/id_rsa', 'passphrase' => 'secret', 'port' => 8222, 'maxTries' => $maxTries, 'timeout' => 1, 'connectivityChecker' => $connectivityChecker, ] ); $this->expectException(UnableToConnectToSftpHost::class); try { $provider->provideConnection(); } finally { self::assertSame($maxTries + 1, $connectivityChecker->calls); } } /** * @test */ public function authenticate_with_supported_preferred_kex_algorithm_succeeds(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, 'preferredAlgorithms' => [ 'kex' => [self::KEX_ACCEPTED_BY_DEFAULT_OPENSSH_BUT_DISABLED_IN_EDDSA_ONLY], ], ] ); $this->runWithRetries(fn () => $this->assertInstanceOf(SFTP::class, $provider->provideConnection())); $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2223, 'preferredAlgorithms' => [ 'kex' => ['curve25519-sha256'], ], ] ); $this->runWithRetries(fn () => $this->assertInstanceOf(SFTP::class, $provider->provideConnection())); } /** * @test */ public function authenticate_with_unsupported_preferred_kex_algorithm_failes(): void { $provider = SftpConnectionProvider::fromArray( [ 'host' => 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2223, 'preferredAlgorithms' => [ 'kex' => [self::KEX_ACCEPTED_BY_DEFAULT_OPENSSH_BUT_DISABLED_IN_EDDSA_ONLY], ], ] ); $this->expectException(UnableToConnectToSftpHost::class); $provider->provideConnection(); } private function computeFingerPrint(string $publicKey): string { $content = explode(' ', $publicKey, 3); $algo = $content[0] === 'ssh-rsa' ? 'md5' : 'sha512'; return implode(':', str_split(hash($algo, base64_decode($content[1])), 2)); } /** * @param class-string|null $expected * * @throws Throwable */ public function runWithRetries(callable $scenario, ?string $expected = null): void { $tries = 0; start: try { $scenario(); } catch (Throwable $exception) { if (($expected === null || is_a($exception, $expected) === false) && $tries < 10) { $tries++; sleep($tries); goto start; } throw $exception; } } } ================================================ FILE: src/PhpseclibV3/SftpStub.php ================================================ */ private array $tripWires = []; public function failOnChmod(string $filename): void { $key = $this->formatTripKey('chmod', $filename); $this->tripWires[$key] = true; } /** * @param int $mode * @param string $filename * @param bool $recursive * * @return bool|mixed */ public function chmod($mode, $filename, $recursive = false) { $key = $this->formatTripKey('chmod', $filename); $shouldTrip = $this->tripWires[$key] ?? false; if ($shouldTrip) { unset($this->tripWires[$key]); return false; } return parent::chmod($mode, $filename, $recursive); } public function failOnPut(string $filename): void { $key = $this->formatTripKey('put', $filename); $this->tripWires[$key] = true; } /** * @param string $remote_file * @param resource|string $data * @param int $mode * @param int $start * @param int $local_start * @param null $progressCallback * * @return bool */ public function put( $remote_file, $data, $mode = self::SOURCE_STRING, $start = -1, $local_start = -1, $progressCallback = null ) { $key = $this->formatTripKey('put', $remote_file); $shouldTrip = $this->tripWires[$key] ?? false; if ($shouldTrip) { return false; } return parent::put($remote_file, $data, $mode, $start, $local_start, $progressCallback); } /** * @param array $arguments * * @return string */ private function formatTripKey(...$arguments): string { $key = ''; foreach ($arguments as $argument) { $key .= var_export($argument, true); } return $key; } public function resetTripWires(): void { $this->tripWires = []; } } ================================================ FILE: src/PhpseclibV3/SimpleConnectivityChecker.php ================================================ usePing = $usePing; return $clone; } public function isConnected(SFTP $connection): bool { if ( ! $connection->isConnected()) { return false; } if ( ! $this->usePing) { return true; } try { return $connection->ping(); } catch (Throwable) { return false; } } } ================================================ FILE: src/PhpseclibV3/StubSftpConnectionProvider.php ================================================ connection) { $this->connection->disconnect(); } } public function provideConnection(): SFTP { if ( ! $this->connection instanceof SFTP || ! $this->connection->isConnected()) { $connection = new SftpStub($this->host, $this->port); $connection->login($this->username, $this->password); $this->connection = $connection; } return $this->connection; } } ================================================ FILE: src/PhpseclibV3/UnableToAuthenticate.php ================================================ connectionError = $lastError; } public static function withPassword(?string $lastError = null): UnableToAuthenticate { return new UnableToAuthenticate('Unable to authenticate using a password.', $lastError); } public static function withPrivateKey(?string $lastError = null): UnableToAuthenticate { return new UnableToAuthenticate('Unable to authenticate using a private key.', $lastError); } public static function withSshAgent(?string $lastError = null): UnableToAuthenticate { return new UnableToAuthenticate('Unable to authenticate using an SSH agent.', $lastError); } public function connectionError(): ?string { return $this->connectionError; } } ================================================ FILE: src/PhpseclibV3/UnableToConnectToSftpHost.php ================================================ formatPropertyName((string) $offset); return isset($this->{$property}); } /** * @param mixed $offset * * @return mixed */ #[\ReturnTypeWillChange] public function offsetGet($offset) { $property = $this->formatPropertyName((string) $offset); return $this->{$property}; } /** * @param mixed $offset * @param mixed $value */ #[\ReturnTypeWillChange] public function offsetSet($offset, $value): void { throw new RuntimeException('Properties can not be manipulated'); } /** * @param mixed $offset */ #[\ReturnTypeWillChange] public function offsetUnset($offset): void { throw new RuntimeException('Properties can not be manipulated'); } } ================================================ FILE: src/ReadOnly/.gitattributes ================================================ * text=auto .github export-ignore .gitattributes export-ignore .gitignore export-ignore **/*Test.php export-ignore README.md export-ignore ================================================ FILE: src/ReadOnly/.github/workflows/close-subsplit-prs.yaml ================================================ --- name: Close sub-split PRs on: push: branches: - 2.x - 3.x pull_request: branches: - 2.x - 3.x schedule: - cron: '30 7 * * *' jobs: close_subsplit_prs: runs-on: ubuntu-latest name: Close sub-split PRs steps: - uses: frankdejonge/action-close-subsplit-pr@0.1.0 with: close_pr: 'yes' target_branch_match: '^(?!master).+$' message: | Hi :wave:, Thank you for contributing to Flysystem. Unfortunately, you've sent a PR to a read-only sub-split repository. All pull requests should be directed towards: https://github.com/thephpleague/flysystem ================================================ FILE: src/ReadOnly/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/ReadOnly/README.md ================================================ ## Sub-split of Flysystem for read-only adapter decoration. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-read-only ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/read-only/). ================================================ FILE: src/ReadOnly/ReadOnlyFilesystemAdapter.php ================================================ adapter instanceof PublicUrlGenerator) { throw UnableToGeneratePublicUrl::noGeneratorConfigured($path); } return $this->adapter->publicUrl($path, $config); } public function checksum(string $path, Config $config): string { if ($this->adapter instanceof ChecksumProvider) { return $this->adapter->checksum($path, $config); } return $this->calculateChecksumFromStream($path, $config); } public function temporaryUrl(string $path, DateTimeInterface $expiresAt, Config $config): string { if ( ! $this->adapter instanceof TemporaryUrlGenerator) { throw UnableToGenerateTemporaryUrl::noGeneratorConfigured($path); } return $this->adapter->temporaryUrl($path, $expiresAt, $config); } } ================================================ FILE: src/ReadOnly/ReadOnlyFilesystemAdapterTest.php ================================================ realAdapter(); $adapter->write('foo/bar.txt', 'content', new Config()); $adapter = new ReadOnlyFilesystemAdapter($adapter); $this->assertTrue($adapter->fileExists('foo/bar.txt')); $this->assertTrue($adapter->directoryExists('foo')); $this->assertSame('content', $adapter->read('foo/bar.txt')); $this->assertSame('content', \stream_get_contents($adapter->readStream('foo/bar.txt'))); $this->assertInstanceOf(FileAttributes::class, $adapter->visibility('foo/bar.txt')); $this->assertInstanceOf(FileAttributes::class, $adapter->mimeType('foo/bar.txt')); $this->assertInstanceOf(FileAttributes::class, $adapter->lastModified('foo/bar.txt')); $this->assertInstanceOf(FileAttributes::class, $adapter->fileSize('foo/bar.txt')); $this->assertCount(1, iterator_to_array($adapter->listContents('foo', true))); } /** * @test */ public function cannot_write_stream(): void { $adapter = new ReadOnlyFilesystemAdapter($this->realAdapter()); $this->expectException(UnableToWriteFile::class); // @phpstan-ignore-next-line $adapter->writeStream('foo', 'content', new Config()); } /** * @test */ public function cannot_write(): void { $adapter = new ReadOnlyFilesystemAdapter($this->realAdapter()); $this->expectException(UnableToWriteFile::class); $adapter->write('foo', 'content', new Config()); } /** * @test */ public function cannot_delete_file(): void { $adapter = $this->realAdapter(); $adapter->write('foo', 'content', new Config()); $adapter = new ReadOnlyFilesystemAdapter($adapter); $this->expectException(UnableToDeleteFile::class); $adapter->delete('foo'); } /** * @test */ public function cannot_delete_directory(): void { $adapter = $this->realAdapter(); $adapter->createDirectory('foo', new Config()); $adapter = new ReadOnlyFilesystemAdapter($adapter); $this->expectException(UnableToDeleteDirectory::class); $adapter->deleteDirectory('foo'); } /** * @test */ public function cannot_create_directory(): void { $adapter = new ReadOnlyFilesystemAdapter($this->realAdapter()); $this->expectException(UnableToCreateDirectory::class); $adapter->createDirectory('foo', new Config()); } /** * @test */ public function cannot_set_visibility(): void { $adapter = $this->realAdapter(); $adapter->write('foo', 'content', new Config()); $adapter = new ReadOnlyFilesystemAdapter($adapter); $this->expectException(UnableToSetVisibility::class); $adapter->setVisibility('foo', 'private'); } /** * @test */ public function cannot_move(): void { $adapter = $this->realAdapter(); $adapter->write('foo', 'content', new Config()); $adapter = new ReadOnlyFilesystemAdapter($adapter); $this->expectException(UnableToMoveFile::class); $adapter->move('foo', 'bar', new Config()); } /** * @test */ public function cannot_copy(): void { $adapter = $this->realAdapter(); $adapter->write('foo', 'content', new Config()); $adapter = new ReadOnlyFilesystemAdapter($adapter); $this->expectException(UnableToCopyFile::class); $adapter->copy('foo', 'bar', new Config()); } /** * @test */ public function generating_a_public_url(): void { $adapter = new class() extends InMemoryFilesystemAdapter implements PublicUrlGenerator { public function publicUrl(string $path, Config $config): string { return 'memory://' . ltrim($path, '/'); } }; $readOnlyAdapter = new ReadOnlyFilesystemAdapter($adapter); $url = $readOnlyAdapter->publicUrl('/path.txt', new Config()); self::assertEquals('memory://path.txt', $url); } /** * @test */ public function failing_to_generate_a_public_url(): void { $adapter = new ReadOnlyFilesystemAdapter(new InMemoryFilesystemAdapter()); $this->expectException(UnableToGeneratePublicUrl::class); $adapter->publicUrl('/path.txt', new Config()); } private function realAdapter(): InMemoryFilesystemAdapter { return new InMemoryFilesystemAdapter(); } } ================================================ FILE: src/ReadOnly/composer.json ================================================ { "name": "league/flysystem-read-only", "description": "Read-only filesystem adapter for Flysystem.", "keywords": ["flysystem", "filesystem", "read-only", "read", "only"], "type": "library", "prefer-stable": true, "autoload": { "psr-4": { "League\\Flysystem\\ReadOnly\\": "" } }, "require": { "php": "^8.0.2", "league/flysystem": "^3.10.0" }, "license": "MIT", "authors": [ { "name": "Frank de Jonge", "email": "info@frankdejonge.nl" } ] } ================================================ FILE: src/ResolveIdenticalPathConflict.php ================================================ location; } public static function atLocation(string $pathName): SymbolicLinkEncountered { $e = new static("Unsupported symbolic link encountered at location $pathName"); $e->location = $pathName; return $e; } } ================================================ FILE: src/UnableToCheckDirectoryExistence.php ================================================ source; } public function destination(): string { return $this->destination; } public static function fromLocationTo( string $sourcePath, string $destinationPath, ?Throwable $previous = null ): UnableToCopyFile { $e = new static("Unable to copy file from $sourcePath to $destinationPath", 0 , $previous); $e->source = $sourcePath; $e->destination = $destinationPath; return $e; } public static function sourceAndDestinationAreTheSame(string $source, string $destination): UnableToCopyFile { return UnableToCopyFile::because('Source and destination are the same', $source, $destination); } public static function because(string $reason, string $sourcePath, string $destinationPath): UnableToCopyFile { $e = new static("Unable to copy file from $sourcePath to $destinationPath, because $reason"); $e->source = $sourcePath; $e->destination = $destinationPath; return $e; } public function operation(): string { return FilesystemOperationFailed::OPERATION_COPY; } } ================================================ FILE: src/UnableToCreateDirectory.php ================================================ location = $dirname; $e->reason = $errorMessage; return $e; } public static function dueToFailure(string $dirname, Throwable $previous): UnableToCreateDirectory { $reason = $previous instanceof UnableToCreateDirectory ? $previous->reason() : ''; $message = "Unable to create a directory at $dirname. $reason"; $e = new static(rtrim($message), 0, $previous); $e->location = $dirname; $e->reason = $reason ?: $message; return $e; } public function operation(): string { return FilesystemOperationFailed::OPERATION_CREATE_DIRECTORY; } public function reason(): string { return $this->reason; } public function location(): string { return $this->location; } } ================================================ FILE: src/UnableToDeleteDirectory.php ================================================ location = $location; $e->reason = $reason; return $e; } public function operation(): string { return FilesystemOperationFailed::OPERATION_DELETE_DIRECTORY; } public function reason(): string { return $this->reason; } public function location(): string { return $this->location; } } ================================================ FILE: src/UnableToDeleteFile.php ================================================ location = $location; $e->reason = $reason; return $e; } public function operation(): string { return FilesystemOperationFailed::OPERATION_DELETE; } public function reason(): string { return $this->reason; } public function location(): string { return $this->location; } } ================================================ FILE: src/UnableToGeneratePublicUrl.php ================================================ getMessage(), $path, $exception); } public static function noGeneratorConfigured(string $path, string $extraReason = ''): static { return new static('No generator was configured ' . $extraReason, $path); } } ================================================ FILE: src/UnableToGenerateTemporaryUrl.php ================================================ getMessage(), $path, $exception); } public static function noGeneratorConfigured(string $path, string $extraReason = ''): static { return new static('No generator was configured ' . $extraReason, $path); } } ================================================ FILE: src/UnableToListContents.php ================================================ getMessage(); return new UnableToListContents($message, 0, $previous); } public function operation(): string { return self::OPERATION_LIST_CONTENTS; } } ================================================ FILE: src/UnableToMountFilesystem.php ================================================ source; } public function destination(): string { return $this->destination; } public static function fromLocationTo( string $sourcePath, string $destinationPath, ?Throwable $previous = null ): UnableToMoveFile { $message = $previous?->getMessage() ?? "Unable to move file from $sourcePath to $destinationPath"; $e = new static($message, 0, $previous); $e->source = $sourcePath; $e->destination = $destinationPath; return $e; } public static function because( string $reason, string $sourcePath, string $destinationPath, ): UnableToMoveFile { $message = "Unable to move file from $sourcePath to $destinationPath, because $reason"; $e = new static($message); $e->source = $sourcePath; $e->destination = $destinationPath; return $e; } public function operation(): string { return FilesystemOperationFailed::OPERATION_MOVE; } } ================================================ FILE: src/UnableToProvideChecksum.php ================================================ location = $location; $e->reason = $reason; return $e; } public function operation(): string { return FilesystemOperationFailed::OPERATION_READ; } public function reason(): string { return $this->reason; } public function location(): string { return $this->location; } } ================================================ FILE: src/UnableToResolveFilesystemMount.php ================================================ reason = $reason; $e->location = $location; $e->metadataType = $type; return $e; } public function reason(): string { return $this->reason; } public function location(): string { return $this->location; } public function metadataType(): string { return $this->metadataType; } public function operation(): string { return FilesystemOperationFailed::OPERATION_RETRIEVE_METADATA; } } ================================================ FILE: src/UnableToSetVisibility.php ================================================ reason; } public static function atLocation(string $filename, string $extraMessage = '', ?Throwable $previous = null): self { $message = "Unable to set visibility for file {$filename}. $extraMessage"; $e = new static(rtrim($message), 0, $previous); $e->reason = $extraMessage; $e->location = $filename; return $e; } public function operation(): string { return FilesystemOperationFailed::OPERATION_SET_VISIBILITY; } public function location(): string { return $this->location; } } ================================================ FILE: src/UnableToWriteFile.php ================================================ location = $location; $e->reason = $reason; return $e; } public function operation(): string { return FilesystemOperationFailed::OPERATION_WRITE; } public function reason(): string { return $this->reason; } public function location(): string { return $this->location; } } ================================================ FILE: src/UnixVisibility/PortableVisibilityConverter.php ================================================ filePublic : $this->filePrivate; } public function forDirectory(string $visibility): int { PortableVisibilityGuard::guardAgainstInvalidInput($visibility); return $visibility === Visibility::PUBLIC ? $this->directoryPublic : $this->directoryPrivate; } public function inverseForFile(int $visibility): string { if ($visibility === $this->filePublic) { return Visibility::PUBLIC; } elseif ($visibility === $this->filePrivate) { return Visibility::PRIVATE; } return Visibility::PUBLIC; // default } public function inverseForDirectory(int $visibility): string { if ($visibility === $this->directoryPublic) { return Visibility::PUBLIC; } elseif ($visibility === $this->directoryPrivate) { return Visibility::PRIVATE; } return Visibility::PUBLIC; // default } public function defaultForDirectories(): int { return $this->defaultForDirectories === Visibility::PUBLIC ? $this->directoryPublic : $this->directoryPrivate; } /** * @param array $permissionMap */ public static function fromArray(array $permissionMap, string $defaultForDirectories = Visibility::PRIVATE): PortableVisibilityConverter { return new PortableVisibilityConverter( $permissionMap['file']['public'] ?? 0644, $permissionMap['file']['private'] ?? 0600, $permissionMap['dir']['public'] ?? 0755, $permissionMap['dir']['private'] ?? 0700, $defaultForDirectories ); } } ================================================ FILE: src/UnixVisibility/PortableVisibilityConverterTest.php ================================================ assertEquals(0644, $interpreter->forFile(Visibility::PUBLIC)); $this->assertEquals(0600, $interpreter->forFile(Visibility::PRIVATE)); } /** * @test */ public function determining_an_incorrect_visibility_for_a_file(): void { $this->expectException(InvalidVisibilityProvided::class); $interpreter = new PortableVisibilityConverter(); $interpreter->forFile('incorrect'); } /** * @test */ public function determining_visibility_for_a_directory(): void { $interpreter = new PortableVisibilityConverter(); $this->assertEquals(0755, $interpreter->forDirectory(Visibility::PUBLIC)); $this->assertEquals(0700, $interpreter->forDirectory(Visibility::PRIVATE)); } /** * @test */ public function determining_an_incorrect_visibility_for_a_directory(): void { $this->expectException(InvalidVisibilityProvided::class); $interpreter = new PortableVisibilityConverter(); $interpreter->forDirectory('incorrect'); } /** * @test */ public function inversing_for_a_file(): void { $interpreter = new PortableVisibilityConverter(); $this->assertEquals(Visibility::PUBLIC, $interpreter->inverseForFile(0644)); $this->assertEquals(Visibility::PRIVATE, $interpreter->inverseForFile(0600)); $this->assertEquals(Visibility::PUBLIC, $interpreter->inverseForFile(0404)); } /** * @test */ public function inversing_for_a_directory(): void { $interpreter = new PortableVisibilityConverter(); $this->assertEquals(Visibility::PUBLIC, $interpreter->inverseForDirectory(0755)); $this->assertEquals(Visibility::PRIVATE, $interpreter->inverseForDirectory(0700)); $this->assertEquals(Visibility::PUBLIC, $interpreter->inverseForDirectory(0404)); } /** * @test */ public function determining_default_for_directories(): void { $interpreter = new PortableVisibilityConverter(); $this->assertEquals(0700, $interpreter->defaultForDirectories()); $interpreter = new PortableVisibilityConverter(0644, 0600, 0755, 0700, Visibility::PUBLIC); $this->assertEquals(0755, $interpreter->defaultForDirectories()); } /** * @test */ public function creating_from_array(): void { $interpreter = PortableVisibilityConverter::fromArray([ 'file' => [ 'public' => 0640, 'private' => 0604, ], 'dir' => [ 'public' => 0740, 'private' => 7604, ], ]); $this->assertEquals(0640, $interpreter->forFile(Visibility::PUBLIC)); $this->assertEquals(0604, $interpreter->forFile(Visibility::PRIVATE)); $this->assertEquals(0740, $interpreter->forDirectory(Visibility::PUBLIC)); $this->assertEquals(7604, $interpreter->forDirectory(Visibility::PRIVATE)); } } ================================================ FILE: src/UnixVisibility/VisibilityConverter.php ================================================ location; } public static function atLocation(string $location): UnreadableFileEncountered { $e = new static("Unreadable file encountered at location {$location}."); $e->location = $location; return $e; } } ================================================ FILE: src/UrlGeneration/ChainedPublicUrlGenerator.php ================================================ generators as $generator) { try { return $generator->publicUrl($path, $config); } catch (UnableToGeneratePublicUrl) { } } throw new UnableToGeneratePublicUrl('No supported public url generator found.', $path); } } ================================================ FILE: src/UrlGeneration/ChainedPublicUrlGeneratorTest.php ================================================ assertSame('/prefix/some/path', $generator->publicUrl('some/path', new Config())); } /** * @test */ public function no_supported_generator_found_throws_exception(): void { $generator = new ChainedPublicUrlGenerator([ new class() implements PublicUrlGenerator { public function publicUrl(string $path, Config $config): string { throw new UnableToGeneratePublicUrl('not supported', $path); } }, ]); $this->expectException(UnableToGeneratePublicUrl::class); $this->expectExceptionMessage('Unable to generate public url for some/path: No supported public url generator found.'); $generator->publicUrl('some/path', new Config()); } } ================================================ FILE: src/UrlGeneration/PrefixPublicUrlGenerator.php ================================================ prefixer = new PathPrefixer($urlPrefix, '/'); } public function publicUrl(string $path, Config $config): string { return $this->prefixer->prefixPath($path); } } ================================================ FILE: src/UrlGeneration/PublicUrlGenerator.php ================================================ count = count($prefixes); if ($this->count === 0) { throw new InvalidArgumentException('At least one prefix is required.'); } $this->prefixes = array_map(static fn (string $prefix) => new PathPrefixer($prefix, '/'), $prefixes); } public function publicUrl(string $path, Config $config): string { $index = abs(crc32($path)) % $this->count; return $this->prefixes[$index]->prefixPath($path); } } ================================================ FILE: src/UrlGeneration/TemporaryUrlGenerator.php ================================================ 'http://localhost:4080/', 'userName' => 'alice', 'password' => 'secret1234']); return new WebDAVAdapter($client, manualCopy: true, manualMove: true); } } ================================================ FILE: src/WebDAV/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/WebDAV/README.md ================================================ ## Sub-split of Flysystem for WebDAV using sabre/dav. > ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ```bash composer require league/flysystem-webdav ``` View the [documentation](https://flysystem.thephpleague.com/docs/adapter/webdav). ================================================ FILE: src/WebDAV/SabreServerTest.php ================================================ 'http://localhost:4040/']); return new WebDAVAdapter($client, 'directory/prefix'); } } ================================================ FILE: src/WebDAV/UrlPrefixingClientStub.php ================================================ $object) { $formatted['https://domain.tld/' . ltrim($path, '/')] = $object; } return $formatted; } } ================================================ FILE: src/WebDAV/WebDAVAdapter.php ================================================ prefixer = new PathPrefixer($prefix); } public function fileExists(string $path): bool { $location = $this->encodePath($this->prefixer->prefixPath($path)); try { $properties = $this->client->propFind($location, ['{DAV:}resourcetype', '{DAV:}iscollection']); return ! $this->propsIsDirectory($properties); } catch (Throwable $exception) { if ($exception instanceof ClientHttpException && $exception->getHttpStatus() === 404) { return false; } throw UnableToCheckFileExistence::forLocation($path, $exception); } } protected function encodePath(string $path): string { $parts = explode('/', $path); foreach ($parts as $i => $part) { $parts[$i] = rawurlencode($part); } return implode('/', $parts); } public function directoryExists(string $path): bool { $location = $this->encodePath($this->prefixer->prefixPath($path)); try { $properties = $this->client->propFind($location, ['{DAV:}resourcetype', '{DAV:}iscollection']); return $this->propsIsDirectory($properties); } catch (Throwable $exception) { if ($exception instanceof ClientHttpException && $exception->getHttpStatus() === 404) { return false; } throw UnableToCheckDirectoryExistence::forLocation($path, $exception); } } public function write(string $path, string $contents, Config $config): void { $this->upload($path, $contents); } public function writeStream(string $path, $contents, Config $config): void { $this->upload($path, $contents); } /** * @param resource|string $contents */ private function upload(string $path, mixed $contents): void { $this->createParentDirFor($path); $location = $this->encodePath($this->prefixer->prefixPath($path)); try { $response = $this->client->request('PUT', $location, $contents); $statusCode = $response['statusCode']; if ($statusCode < 200 || $statusCode >= 300) { throw new RuntimeException('Unexpected status code received: ' . $statusCode); } } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); } } public function read(string $path): string { $location = $this->encodePath($this->prefixer->prefixPath($path)); try { $response = $this->client->request('GET', $location); if ($response['statusCode'] !== 200) { throw new RuntimeException('Unexpected response code for GET: ' . $response['statusCode']); } return $response['body']; } catch (Throwable $exception) { throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); } } public function readStream(string $path) { $location = $this->encodePath($this->prefixer->prefixPath($path)); try { $url = $this->client->getAbsoluteUrl($location); $request = new Request('GET', $url); $response = $this->client->send($request); $status = $response->getStatus(); if ($status !== 200) { throw new RuntimeException('Unexpected response code for GET: ' . $status); } return $response->getBodyAsStream(); } catch (Throwable $exception) { throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); } } public function delete(string $path): void { $location = $this->encodePath($this->prefixer->prefixPath($path)); try { $response = $this->client->request('DELETE', $location); $statusCode = $response['statusCode']; if ($statusCode !== 404 && ($statusCode < 200 || $statusCode >= 300)) { throw new RuntimeException('Unexpected status code received while deleting file: ' . $statusCode); } } catch (Throwable $exception) { if ( ! ($exception instanceof ClientHttpException && $exception->getCode() === 404)) { throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception); } } } public function deleteDirectory(string $path): void { $location = $this->encodePath($this->prefixer->prefixDirectoryPath($path)); try { $statusCode = $this->client->request('DELETE', $location)['statusCode']; if ($statusCode !== 404 && ($statusCode < 200 || $statusCode >= 300)) { throw new RuntimeException('Unexpected status code received while deleting file: ' . $statusCode); } } catch (Throwable $exception) { if ( ! ($exception instanceof ClientHttpException && $exception->getCode() === 404)) { throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception); } } } public function createDirectory(string $path, Config $config): void { $parts = explode('/', $this->prefixer->prefixDirectoryPath($path)); $directoryParts = []; foreach ($parts as $directory) { if ($directory === '.' || $directory === '') { return; } $directoryParts[] = $directory; $directoryPath = implode('/', $directoryParts); $location = $this->encodePath($directoryPath) . '/'; if ($this->directoryExists($this->prefixer->stripDirectoryPrefix($directoryPath))) { continue; } try { $response = $this->client->request('MKCOL', $location); } catch (Throwable $exception) { throw UnableToCreateDirectory::dueToFailure($path, $exception); } if ($response['statusCode'] === 405) { continue; } if ($response['statusCode'] !== 201) { throw UnableToCreateDirectory::atLocation($path, 'Failed to create directory at: ' . $location); } } } public function setVisibility(string $path, string $visibility): void { if ($this->visibilityHandling === self::ON_VISIBILITY_THROW_ERROR) { throw UnableToSetVisibility::atLocation($path, 'WebDAV does not support this operation.'); } } public function visibility(string $path): FileAttributes { throw UnableToRetrieveMetadata::visibility($path, 'WebDAV does not support this operation.'); } public function mimeType(string $path): FileAttributes { $mimeType = (string) $this->propFind($path, 'mime_type', '{DAV:}getcontenttype'); return new FileAttributes($path, mimeType: $mimeType); } public function lastModified(string $path): FileAttributes { $lastModified = $this->propFind($path, 'last_modified', '{DAV:}getlastmodified'); return new FileAttributes($path, lastModified: strtotime($lastModified)); } public function fileSize(string $path): FileAttributes { $fileSize = (int) $this->propFind($path, 'file_size', '{DAV:}getcontentlength'); return new FileAttributes($path, fileSize: $fileSize); } public function listContents(string $path, bool $deep): iterable { $location = $this->encodePath($this->prefixer->prefixDirectoryPath($path)); $response = $this->client->propFind($location, self::FIND_PROPERTIES, 1); // This is the directory itself, the files are subsequent entries. array_shift($response); foreach ($response as $path => $object) { $path = (string) parse_url(rawurldecode($path), PHP_URL_PATH); $path = $this->prefixer->stripPrefix($path); $object = $this->normalizeObject($object); if ($this->propsIsDirectory($object)) { yield new DirectoryAttributes($path, lastModified: $object['last_modified'] ?? null); if ( ! $deep) { continue; } foreach ($this->listContents($path, true) as $child) { yield $child; } } else { yield new FileAttributes( $path, fileSize: $object['file_size'] ?? null, lastModified: $object['last_modified'] ?? null, mimeType: $object['mime_type'] ?? null, ); } } } private function normalizeObject(array $object): array { $mapping = [ '{DAV:}getcontentlength' => 'file_size', '{DAV:}getcontenttype' => 'mime_type', 'content-length' => 'file_size', 'content-type' => 'mime_type', ]; foreach ($mapping as $from => $to) { if (array_key_exists($from, $object)) { $object[$to] = $object[$from]; } } array_key_exists('file_size', $object) && $object['file_size'] = (int) $object['file_size']; if (array_key_exists('{DAV:}getlastmodified', $object)) { $object['last_modified'] = strtotime($object['{DAV:}getlastmodified']); } return $object; } public function move(string $source, string $destination, Config $config): void { if ($source === $destination) { return; } if ($this->manualMove) { $this->manualMove($source, $destination); return; } $this->createParentDirFor($destination); $location = $this->encodePath($this->prefixer->prefixPath($source)); $newLocation = $this->encodePath($this->prefixer->prefixPath($destination)); try { $response = $this->client->request('MOVE', $location, null, [ 'Destination' => $this->client->getAbsoluteUrl($newLocation), ]); if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) { throw new RuntimeException('MOVE command returned unexpected status code: ' . $response['statusCode'] . "\n{$response['body']}"); } } catch (Throwable $e) { throw UnableToMoveFile::fromLocationTo($source, $destination, $e); } } private function manualMove(string $source, string $destination): void { try { $handle = $this->readStream($source); $this->writeStream($destination, $handle, new Config()); @fclose($handle); $this->delete($source); } catch (Throwable $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } } public function copy(string $source, string $destination, Config $config): void { if ($source === $destination) { return; } if ($this->manualCopy) { $this->manualCopy($source, $destination); return; } $this->createParentDirFor($destination); $location = $this->encodePath($this->prefixer->prefixPath($source)); $newLocation = $this->encodePath($this->prefixer->prefixPath($destination)); try { $response = $this->client->request('COPY', $location, null, [ 'Destination' => $this->client->getAbsoluteUrl($newLocation), ]); if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) { throw new RuntimeException('COPY command returned unexpected status code: ' . $response['statusCode']); } } catch (Throwable $e) { throw UnableToCopyFile::fromLocationTo($source, $destination, $e); } } private function manualCopy(string $source, string $destination): void { try { $handle = $this->readStream($source); $this->writeStream($destination, $handle, new Config()); @fclose($handle); } catch (Throwable $exception) { throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } private function propsIsDirectory(array $properties): bool { if (isset($properties['{DAV:}resourcetype'])) { /** @var ResourceType $resourceType */ $resourceType = $properties['{DAV:}resourcetype']; return $resourceType->is('{DAV:}collection'); } return isset($properties['{DAV:}iscollection']) && $properties['{DAV:}iscollection'] === '1'; } private function createParentDirFor(string $path): void { $dirname = dirname($path); if ($this->directoryExists($dirname)) { return; } $this->createDirectory($dirname, new Config()); } private function propFind(string $path, string $section, string $property): mixed { $location = $this->encodePath($this->prefixer->prefixPath($path)); try { $result = $this->client->propFind($location, [$property]); if ( ! array_key_exists($property, $result)) { throw new RuntimeException('Invalid response, missing key: ' . $property); } return $result[$property]; } catch (Throwable $exception) { throw UnableToRetrieveMetadata::create($path, $section, $exception->getMessage(), $exception); } } public function publicUrl(string $path, Config $config): string { return $this->client->getAbsoluteUrl($this->encodePath($this->prefixer->prefixPath($path))); } } ================================================ FILE: src/WebDAV/WebDAVAdapterTestCase.php ================================================ adapter(); $this->givenWeHaveAnExistingFile('some/file.txt'); $this->expectException(UnableToSetVisibility::class); $adapter->setVisibility('some/file.txt', Visibility::PRIVATE); } /** * @test */ public function overwriting_a_file(): void { $this->runScenario(function () { $this->givenWeHaveAnExistingFile('path.txt', 'contents'); $adapter = $this->adapter(); $adapter->write('path.txt', 'new contents', new Config()); $contents = $adapter->read('path.txt'); $this->assertEquals('new contents', $contents); }); } /** * @test */ public function creating_a_directory_with_leading_and_trailing_slashes(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->createDirectory('/some/directory/', new Config()); self::assertTrue($adapter->directoryExists('/some/directory/')); }); } /** * @test */ public function copying_a_file(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config() ); $adapter->copy('source.txt', 'destination.txt', new Config()); $this->assertTrue($adapter->fileExists('source.txt')); $this->assertTrue($adapter->fileExists('destination.txt')); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function copying_a_file_again(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config() ); $adapter->copy('source.txt', 'destination.txt', new Config()); $this->assertTrue($adapter->fileExists('source.txt')); $this->assertTrue($adapter->fileExists('destination.txt')); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function moving_a_file(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be copied', new Config() ); $adapter->move('source.txt', 'destination.txt', new Config()); $this->assertFalse( $adapter->fileExists('source.txt'), 'After moving a file should no longer exist in the original location.' ); $this->assertTrue( $adapter->fileExists('destination.txt'), 'After moving, a file should be present at the new location.' ); $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); }); } /** * @test */ public function moving_a_file_that_does_not_exist(): void { $this->expectException(UnableToMoveFile::class); $this->runScenario(function () { $this->adapter()->move('source.txt', 'destination.txt', new Config()); }); } /** * @test */ public function part_of_prefix_already_exists(): void { $this->runScenario(function () { $config = new Config(); $adapter1 = new WebDAVAdapter( new Client(['baseUri' => 'http://localhost:4040/']), 'directory1/prefix1', ); $adapter1->createDirectory('folder1', $config); self::assertTrue($adapter1->directoryExists('/folder1')); $adapter2 = new WebDAVAdapter( new Client(['baseUri' => 'http://localhost:4040/']), 'directory1/prefix2', ); $adapter2->createDirectory('folder2', $config); self::assertTrue($adapter2->directoryExists('/folder2')); }); } } ================================================ FILE: src/WebDAV/composer.json ================================================ { "name": "league/flysystem-webdav", "description": "WebDAV filesystem adapter for Flysystem.", "keywords": ["flysystem", "filesystem", "webdav", "files", "file"], "autoload": { "psr-4": { "League\\Flysystem\\WebDAV\\": "" } }, "require": { "php": "^8.0.2", "league/flysystem": "^3.6.0", "sabre/dav": "^4.6.0" }, "license": "MIT", "authors": [ { "name": "Frank de Jonge", "email": "info@frankdejonge.nl" } ] } ================================================ FILE: src/WebDAV/resources/.gitignore ================================================ data index.php ================================================ FILE: src/WebDAV/resources/server.php ================================================ addPlugin(new Sabre\DAV\Browser\Plugin()); if (strpos($_SERVER['REQUEST_URI'], 'unknown-mime-type.md5') === false) { $guesser = new Sabre\DAV\Browser\GuessContentType(); $guesser->extensionMap['svg'] = 'image/svg+xml'; $server->addPlugin($guesser); } $server->start(); ================================================ FILE: src/WhitespacePathNormalizer.php ================================================ rejectFunkyWhiteSpace($path); return $this->normalizeRelativePath($path); } private function rejectFunkyWhiteSpace(string $path): void { if (preg_match('#\p{C}+#u', $path)) { throw CorruptedPathDetected::forPath($path); } } private function normalizeRelativePath(string $path): string { $parts = []; foreach (explode('/', $path) as $part) { switch ($part) { case '': case '.': break; case '..': if (empty($parts)) { throw PathTraversalDetected::forPath($path); } array_pop($parts); break; default: $parts[] = $part; break; } } return implode('/', $parts); } } ================================================ FILE: src/WhitespacePathNormalizerTest.php ================================================ normalizer = new WhitespacePathNormalizer(); } /** * @test * * @dataProvider pathProvider */ public function path_normalizing(string $input, string $expected): void { $result = $this->normalizer->normalizePath($input); $double = $this->normalizer->normalizePath($this->normalizer->normalizePath($input)); $this->assertEquals($expected, $result); $this->assertEquals($expected, $double); } /** * @return array> */ public static function pathProvider(): array { return [ ['.', ''], ['/path/to/dir/.', 'path/to/dir'], ['/dirname/', 'dirname'], ['dirname/..', ''], ['dirname/../', ''], ['dirname./', 'dirname.'], ['dirname/./', 'dirname'], ['dirname/.', 'dirname'], ['./dir/../././', ''], ['/something/deep/../../dirname', 'dirname'], ['00004869/files/other/10-75..stl', '00004869/files/other/10-75..stl'], ['/dirname//subdir///subsubdir', 'dirname/subdir/subsubdir'], ['\dirname\\\\subdir\\\\\\subsubdir', 'dirname/subdir/subsubdir'], ['\\\\some\shared\\\\drive', 'some/shared/drive'], ['C:\dirname\\\\subdir\\\\\\subsubdir', 'C:/dirname/subdir/subsubdir'], ['C:\\\\dirname\subdir\\\\subsubdir', 'C:/dirname/subdir/subsubdir'], ['example/path/..txt', 'example/path/..txt'], ['\\example\\path.txt', 'example/path.txt'], ['\\example\\..\\path.txt', 'path.txt'], ]; } /** * @test * * @dataProvider invalidPathProvider */ public function guarding_against_path_traversal(string $input): void { $this->expectException(PathTraversalDetected::class); $this->normalizer->normalizePath($input); } /** * @test * * @dataProvider dpFunkyWhitespacePaths */ public function rejecting_funky_whitespace(string $path): void { self::expectException(CorruptedPathDetected::class); $this->normalizer->normalizePath($path); } public static function dpFunkyWhitespacePaths(): iterable { return [["some\0/path.txt"], ["s\x09i.php"]]; } /** * @return array> */ public static function invalidPathProvider(): array { return [ ['something/../../../hehe'], ['/something/../../..'], ['..'], ['something\\..\\..'], ['\\something\\..\\..\\dirname'], ]; } } ================================================ FILE: src/ZipArchive/.gitattributes ================================================ * text=auto .github export-ignore .gitattributes export-ignore .gitignore export-ignore **/*Test.php export-ignore README.md export-ignore ================================================ FILE: src/ZipArchive/.github/workflows/close-subsplit-prs.yaml ================================================ --- name: Close sub-split PRs on: push: branches: - 2.x - 3.x pull_request: branches: - 2.x - 3.x schedule: - cron: '30 7 * * *' jobs: close_subsplit_prs: runs-on: ubuntu-latest name: Close sub-split PRs steps: - uses: frankdejonge/action-close-subsplit-pr@0.1.0 with: close_pr: 'yes' target_branch_match: '^(?!master).+$' message: | Hi :wave:, Thank you for contributing to Flysystem. Unfortunately, you've sent a PR to a read-only sub-split repository. All pull requests should be directed towards: https://github.com/thephpleague/flysystem ================================================ FILE: src/ZipArchive/FilesystemZipArchiveProvider.php ================================================ parentDirectoryCreated !== true) { $this->parentDirectoryCreated = true; $this->createParentDirectoryForZipArchive($this->filename); } return $this->openZipArchive(); } private function createParentDirectoryForZipArchive(string $fullPath): void { $dirname = dirname($fullPath); if (is_dir($dirname) || @mkdir($dirname, $this->localDirectoryPermissions, true)) { return; } if ( ! is_dir($dirname)) { throw UnableToCreateParentDirectory::atLocation($fullPath, error_get_last()['message'] ?? ''); } } private function openZipArchive(): ZipArchive { $archive = new ZipArchive(); $success = $archive->open($this->filename, ZipArchive::CREATE); if ($success !== true) { throw UnableToOpenZipArchive::atLocation($this->filename, $archive->getStatusString() ?: ''); } return $archive; } } ================================================ FILE: src/ZipArchive/LICENSE ================================================ Copyright (c) 2013-2026 Frank de Jonge Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: src/ZipArchive/NoRootPrefixZipArchiveAdapterTest.php ================================================ ⚠️ this is a sub-split, for pull requests and issues, visit: https://github.com/thephpleague/flysystem ================================================ FILE: src/ZipArchive/StubZipArchive.php ================================================ failNextDirectoryCreation = true; } /** * @param string $dirname * @param int $flags * * @return bool */ public function addEmptyDir($dirname, $flags = 0): bool { if ($this->failNextDirectoryCreation) { $this->failNextDirectoryCreation = false; return false; } return parent::addEmptyDir($dirname); } public function failNextWrite(): void { $this->failNextWrite = true; } /** * @param string $localname * @param string $contents * @param int $flags * * @return bool */ public function addFromString($localname, $contents, $flags = 0): bool { if ($this->failNextWrite) { $this->failNextWrite = false; return false; } return parent::addFromString($localname, $contents); } public function failNextDeleteName(): void { $this->failNextDeleteName = true; } /** * @return bool */ public function deleteName($name): bool { if ($this->failNextDeleteName) { $this->failNextDeleteName = false; return false; } return parent::deleteName($name); } public function failWhenSettingVisibility(): void { $this->failWhenSettingVisibility = true; } public function setExternalAttributesName($name, $opsys, $attr, $flags = null): bool { if ($this->failWhenSettingVisibility) { $this->failWhenSettingVisibility = false; return false; } return parent::setExternalAttributesName($name, $opsys, $attr); } public function failWhenDeletingAnIndex(): void { $this->failWhenDeletingAnIndex = true; } public function deleteIndex($index): bool { if ($this->failWhenDeletingAnIndex) { $this->failWhenDeletingAnIndex = false; return false; } return parent::deleteIndex($index); } } ================================================ FILE: src/ZipArchive/StubZipArchiveProvider.php ================================================ provider = new FilesystemZipArchiveProvider($filename, $localDirectoryPermissions); } public function createZipArchive(): ZipArchive { if ( ! $this->archive instanceof StubZipArchive) { $zipArchive = $this->provider->createZipArchive(); $zipArchive->close(); unset($zipArchive); $this->archive = new StubZipArchive(); } $this->archive->open($this->filename, ZipArchive::CREATE); return $this->archive; } public function stubbedZipArchive(): StubZipArchive { $this->createZipArchive(); return $this->archive; } } ================================================ FILE: src/ZipArchive/UnableToCreateParentDirectory.php ================================================ pathPrefixer = new PathPrefixer(ltrim($root, '/')); $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); $this->visibility = $visibility ?? new PortableVisibilityConverter(); } public function fileExists(string $path): bool { $archive = $this->zipArchiveProvider->createZipArchive(); $fileExists = $archive->locateName($this->pathPrefixer->prefixPath($path)) !== false; $archive->close(); return $fileExists; } public function write(string $path, string $contents, Config $config): void { try { $this->ensureParentDirectoryExists($path, $config); } catch (Throwable $exception) { throw UnableToWriteFile::atLocation($path, 'creating parent directory failed', $exception); } $archive = $this->zipArchiveProvider->createZipArchive(); $prefixedPath = $this->pathPrefixer->prefixPath($path); if ( ! $archive->addFromString($prefixedPath, $contents)) { throw UnableToWriteFile::atLocation($path, 'writing the file failed'); } $archive->close(); $archive = $this->zipArchiveProvider->createZipArchive(); $visibility = $config->get(Config::OPTION_VISIBILITY); $visibilityResult = $visibility === null || $this->setVisibilityAttribute($prefixedPath, $visibility, $archive); $archive->close(); if ($visibilityResult === false) { throw UnableToWriteFile::atLocation($path, 'setting visibility failed'); } } public function writeStream(string $path, $contents, Config $config): void { $contents = stream_get_contents($contents); if ($contents === false) { throw UnableToWriteFile::atLocation($path, 'Could not get contents of given resource.'); } $this->write($path, $contents, $config); } public function read(string $path): string { $archive = $this->zipArchiveProvider->createZipArchive(); $contents = $archive->getFromName($this->pathPrefixer->prefixPath($path)); $statusString = $archive->getStatusString(); $archive->close(); if ($contents === false) { throw UnableToReadFile::fromLocation($path, $statusString); } return $contents; } public function readStream(string $path) { $archive = $this->zipArchiveProvider->createZipArchive(); $resource = $archive->getStream($this->pathPrefixer->prefixPath($path)); if ($resource === false) { $status = $archive->getStatusString(); $archive->close(); throw UnableToReadFile::fromLocation($path, $status); } $stream = fopen('php://temp', 'w+b'); stream_copy_to_stream($resource, $stream); rewind($stream); fclose($resource); return $stream; } public function delete(string $path): void { $prefixedPath = $this->pathPrefixer->prefixPath($path); $zipArchive = $this->zipArchiveProvider->createZipArchive(); $success = $zipArchive->locateName($prefixedPath) === false || $zipArchive->deleteName($prefixedPath); $statusString = $zipArchive->getStatusString(); $zipArchive->close(); if ( ! $success) { throw UnableToDeleteFile::atLocation($path, $statusString); } } public function deleteDirectory(string $path): void { $archive = $this->zipArchiveProvider->createZipArchive(); $prefixedPath = $this->pathPrefixer->prefixDirectoryPath($path); for ($i = $archive->numFiles; $i > 0; $i--) { if (($stats = $archive->statIndex($i)) === false) { continue; } $itemPath = $stats['name']; if ( ! str_starts_with($itemPath, $prefixedPath)) { continue; } if ( ! $archive->deleteIndex($i)) { $statusString = $archive->getStatusString(); $archive->close(); throw UnableToDeleteDirectory::atLocation($path, $statusString); } } $archive->deleteName($prefixedPath); $archive->close(); } public function createDirectory(string $path, Config $config): void { try { $this->ensureDirectoryExists($path, $config); } catch (Throwable $exception) { throw UnableToCreateDirectory::dueToFailure($path, $exception); } } public function directoryExists(string $path): bool { $archive = $this->zipArchiveProvider->createZipArchive(); $location = $this->pathPrefixer->prefixDirectoryPath($path); return $archive->statName($location) !== false; } public function setVisibility(string $path, string $visibility): void { $archive = $this->zipArchiveProvider->createZipArchive(); $location = $this->pathPrefixer->prefixPath($path); $stats = $archive->statName($location) ?: $archive->statName($location . '/'); if ($stats === false) { $statusString = $archive->getStatusString(); $archive->close(); throw UnableToSetVisibility::atLocation($path, $statusString); } if ( ! $this->setVisibilityAttribute($stats['name'], $visibility, $archive)) { $statusString1 = $archive->getStatusString(); $archive->close(); throw UnableToSetVisibility::atLocation($path, $statusString1); } $archive->close(); } public function visibility(string $path): FileAttributes { $opsys = null; $attr = null; $archive = $this->zipArchiveProvider->createZipArchive(); $archive->getExternalAttributesName( $this->pathPrefixer->prefixPath($path), $opsys, $attr ); $archive->close(); if ($opsys !== ZipArchive::OPSYS_UNIX || $attr === null) { throw UnableToRetrieveMetadata::visibility($path); } return new FileAttributes( $path, null, $this->visibility->inverseForFile($attr >> 16) ); } public function mimeType(string $path): FileAttributes { try { $mimetype = $this->detectMimeTypeUsingPath ? $this->mimeTypeDetector->detectMimeTypeFromPath($path) : $this->mimeTypeDetector->detectMimeType($path, $this->read($path)); } catch (Throwable $exception) { throw UnableToRetrieveMetadata::mimeType($path, $exception->getMessage(), $exception); } if ($mimetype === null) { throw UnableToRetrieveMetadata::mimeType($path, 'Unknown.'); } return new FileAttributes($path, null, null, null, $mimetype); } public function lastModified(string $path): FileAttributes { $zipArchive = $this->zipArchiveProvider->createZipArchive(); $stats = $zipArchive->statName($this->pathPrefixer->prefixPath($path)); $statusString = $zipArchive->getStatusString(); $zipArchive->close(); if ($stats === false) { throw UnableToRetrieveMetadata::lastModified($path, $statusString); } return new FileAttributes($path, null, null, $stats['mtime']); } public function fileSize(string $path): FileAttributes { $archive = $this->zipArchiveProvider->createZipArchive(); $stats = $archive->statName($this->pathPrefixer->prefixPath($path)); $statusString = $archive->getStatusString(); $archive->close(); if ($stats === false) { throw UnableToRetrieveMetadata::fileSize($path, $statusString); } if ($this->isDirectoryPath($stats['name'])) { throw UnableToRetrieveMetadata::fileSize($path, 'It\'s a directory.'); } return new FileAttributes($path, $stats['size'], null, null); } public function listContents(string $path, bool $deep): iterable { $archive = $this->zipArchiveProvider->createZipArchive(); $location = $this->pathPrefixer->prefixDirectoryPath($path); $items = []; for ($i = 0; $i < $archive->numFiles; $i++) { $stats = $archive->statIndex($i); // @codeCoverageIgnoreStart if ($stats === false) { continue; } // @codeCoverageIgnoreEnd $itemPath = $stats['name']; if ( $location === $itemPath || ($deep && $location !== '' && ! str_starts_with($itemPath, $location)) || ($deep === false && ! $this->isAtRootDirectory($location, $itemPath)) ) { continue; } $items[] = $this->isDirectoryPath($itemPath) ? new DirectoryAttributes( $this->pathPrefixer->stripDirectoryPrefix($itemPath), null, $stats['mtime'] ) : new FileAttributes( $this->pathPrefixer->stripPrefix($itemPath), $stats['size'], null, $stats['mtime'] ); } $archive->close(); return $this->yieldItemsFrom($items); } private function yieldItemsFrom(array $items): Generator { yield from $items; } public function move(string $source, string $destination, Config $config): void { try { $this->ensureParentDirectoryExists($destination, $config); } catch (Throwable $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } $archive = $this->zipArchiveProvider->createZipArchive(); if ($archive->locateName($this->pathPrefixer->prefixPath($destination)) !== false) { if ($source === $destination) { //update the config of the file $this->copy($source, $destination, $config); return; } $this->delete($destination); $this->copy($source, $destination, $config); $this->delete($source); return; } $renamed = $archive->renameName( $this->pathPrefixer->prefixPath($source), $this->pathPrefixer->prefixPath($destination) ); if ($renamed === false) { throw UnableToMoveFile::fromLocationTo($source, $destination); } } public function copy(string $source, string $destination, Config $config): void { try { $readStream = $this->readStream($source); $this->writeStream($destination, $readStream, $config); } catch (Throwable $exception) { if (isset($readStream)) { @fclose($readStream); } throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); } } private function ensureParentDirectoryExists(string $path, Config $config): void { $dirname = dirname($path); if ($dirname === '' || $dirname === '.') { return; } $this->ensureDirectoryExists($dirname, $config); } private function ensureDirectoryExists(string $dirname, Config $config): void { $visibility = $config->get(Config::OPTION_DIRECTORY_VISIBILITY); $archive = $this->zipArchiveProvider->createZipArchive(); $prefixedDirname = $this->pathPrefixer->prefixDirectoryPath($dirname); $parts = array_filter(explode('/', trim($prefixedDirname, '/'))); $dirPath = ''; foreach ($parts as $part) { $dirPath .= $part . '/'; $info = $archive->statName($dirPath); if ($info === false && $archive->addEmptyDir($dirPath) === false) { throw UnableToCreateDirectory::atLocation($dirname); } if ($visibility === null) { continue; } if ( ! $this->setVisibilityAttribute($dirPath, $visibility, $archive)) { $archive->close(); throw UnableToCreateDirectory::atLocation($dirname, 'Unable to set visibility.'); } } $archive->close(); } private function isDirectoryPath(string $path): bool { return str_ends_with($path, '/'); } private function isAtRootDirectory(string $directoryRoot, string $path): bool { $dirname = dirname($path); if ('' === $directoryRoot && '.' === $dirname) { return true; } return $directoryRoot === (rtrim($dirname, '/') . '/'); } private function setVisibilityAttribute(string $statsName, string $visibility, ZipArchive $archive): bool { $visibility = $this->isDirectoryPath($statsName) ? $this->visibility->forDirectory($visibility) : $this->visibility->forFile($visibility); return $archive->setExternalAttributesName($statsName, ZipArchive::OPSYS_UNIX, $visibility << 16); } } ================================================ FILE: src/ZipArchive/ZipArchiveAdapterTestCase.php ================================================ expectException(UnableToCreateParentDirectory::class); (new ZipArchiveAdapter(new StubZipArchiveProvider('/no-way/this/will/work'))) ->write('haha', 'lol', new Config()); } /** * @test */ public function not_being_able_to_write_a_file_because_the_parent_directory_could_not_be_created(): void { self::$archiveProvider->stubbedZipArchive()->failNextDirectoryCreation(); $this->expectException(UnableToWriteFile::class); $this->adapter()->write('directoryName/is-here/filename.txt', 'contents', new Config()); } /** * @test * * @dataProvider scenariosThatCauseWritesToFail */ public function scenarios_that_cause_writing_a_file_to_fail(callable $scenario): void { $this->runScenario($scenario); $this->expectException(UnableToWriteFile::class); $this->runScenario(function () { $handle = stream_with_contents('contents'); $this->adapter()->writeStream('some/path.txt', $handle, new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC])); is_resource($handle) && @fclose($handle); }); } public static function scenariosThatCauseWritesToFail(): Generator { yield "writing a file fails when writing" => [function () { static::$archiveProvider->stubbedZipArchive()->failNextWrite(); }]; yield "writing a file fails when setting visibility" => [function () { static::$archiveProvider->stubbedZipArchive()->failWhenSettingVisibility(); }]; yield "writing a file fails to get the stream contents" => [function () { mock_function('stream_get_contents', false); }]; } /** * @test */ public function failing_to_delete_a_file(): void { $this->givenWeHaveAnExistingFile('path.txt'); static::$archiveProvider->stubbedZipArchive()->failNextDeleteName(); $this->expectException(UnableToDeleteFile::class); $this->adapter()->delete('path.txt'); } /** * @test */ public function deleting_a_directory(): void { $this->givenWeHaveAnExistingFile('a.txt'); $this->givenWeHaveAnExistingFile('one/a.txt'); $this->givenWeHaveAnExistingFile('one/b.txt'); $this->givenWeHaveAnExistingFile('two/a.txt'); $items = iterator_to_array($this->adapter()->listContents('', true)); $this->assertCount(6, $items); $this->adapter()->deleteDirectory('one'); $items = iterator_to_array($this->adapter()->listContents('', true)); $this->assertCount(3, $items); } /** * @test */ public function deleting_a_prefixed_directory(): void { $this->givenWeHaveAnExistingFile('a.txt'); $this->givenWeHaveAnExistingFile('/one/a.txt'); $this->givenWeHaveAnExistingFile('one/b.txt'); $this->givenWeHaveAnExistingFile('two/a.txt'); $items = iterator_to_array($this->adapter()->listContents('', true)); $this->assertCount(6, $items); $this->adapter()->deleteDirectory('one'); $items = iterator_to_array($this->adapter()->listContents('', true)); $this->assertCount(3, $items); } /** * @test */ public function list_root_directory(): void { $this->givenWeHaveAnExistingFile('a.txt'); $this->givenWeHaveAnExistingFile('one/a.txt'); $this->givenWeHaveAnExistingFile('one/b.txt'); $this->givenWeHaveAnExistingFile('two/a.txt'); $this->assertCount(6, iterator_to_array($this->adapter()->listContents('', true))); $this->assertCount(3, iterator_to_array($this->adapter()->listContents('', false))); } /** * @test */ public function failing_to_create_a_directory(): void { static::$archiveProvider->stubbedZipArchive()->failNextDirectoryCreation(); $this->expectException(UnableToCreateDirectory::class); $this->adapter()->createDirectory('somewhere', new Config); } /** * @test */ public function failing_to_create_a_directory_because_setting_visibility_fails(): void { static::$archiveProvider->stubbedZipArchive()->failWhenSettingVisibility(); $this->expectException(UnableToCreateDirectory::class); $this->adapter()->createDirectory('somewhere', new Config([Config::OPTION_DIRECTORY_VISIBILITY => Visibility::PRIVATE])); } /** * @test */ public function failing_to_delete_a_directory(): void { static::$archiveProvider->stubbedZipArchive()->failWhenDeletingAnIndex(); $this->givenWeHaveAnExistingFile('here/path.txt'); $this->expectException(UnableToDeleteDirectory::class); $this->adapter()->deleteDirectory('here'); } /** * @test */ public function setting_visibility_on_a_directory(): void { $adapter = $this->adapter(); $adapter->createDirectory('pri-dir', new Config([Config::OPTION_DIRECTORY_VISIBILITY => Visibility::PRIVATE])); $adapter->createDirectory('pub-dir', new Config([Config::OPTION_DIRECTORY_VISIBILITY => Visibility::PUBLIC])); $this->expectNotToPerformAssertions(); } /** * @test */ public function failing_to_move_a_file(): void { $this->givenWeHaveAnExistingFile('somewhere/here.txt'); static::$archiveProvider->stubbedZipArchive()->failNextDirectoryCreation(); $this->expectException(UnableToMoveFile::class); $this->adapter()->move('somewhere/here.txt', 'to-here/path.txt', new Config); } /** * @test */ public function failing_to_copy_a_file(): void { $this->givenWeHaveAnExistingFile('here.txt'); static::$archiveProvider->stubbedZipArchive()->failNextWrite(); $this->expectException(UnableToCopyFile::class); $this->adapter()->copy('here.txt', 'here.txt', new Config); } /** * @test */ public function failing_to_set_visibility_because_the_file_does_not_exist(): void { $this->expectException(UnableToSetVisibility::class); $this->adapter()->setVisibility('path.txt', Visibility::PUBLIC); } /** * @test */ public function deleting_a_directory_with_files_in_it(): void { $this->givenWeHaveAnExistingFile('nested/path-a.txt'); $this->givenWeHaveAnExistingFile('nested/path-b.txt'); $this->adapter()->deleteDirectory('nested'); $listing = iterator_to_array($this->adapter()->listContents('', true)); self::assertEquals([], $listing); } /** * @test */ public function failing_to_set_visibility_because_setting_it_fails(): void { $this->givenWeHaveAnExistingFile('path.txt'); static::$archiveProvider->stubbedZipArchive()->failWhenSettingVisibility(); $this->expectException(UnableToSetVisibility::class); $this->adapter()->setVisibility('path.txt', Visibility::PUBLIC); } /** * @test * * @fixme Move to FilesystemAdapterTestCase once all adapters pass */ public function moving_a_file_and_overwriting(): void { $this->runScenario(function () { $adapter = $this->adapter(); $adapter->write( 'source.txt', 'contents to be moved', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->write( 'destination.txt', 'contents to be overwritten', new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) ); $adapter->move('source.txt', 'destination.txt', new Config()); $this->assertFalse( $adapter->fileExists('source.txt'), 'After moving a file should no longer exist in the original location.' ); $this->assertTrue( $adapter->fileExists('destination.txt'), 'After moving, a file should be present at the new location.' ); $this->assertEquals(Visibility::PUBLIC, $adapter->visibility('destination.txt')->visibility()); $this->assertEquals('contents to be moved', $adapter->read('destination.txt')); }); } protected static function removeZipArchive(): void { if ( ! file_exists(self::ARCHIVE)) { return; } unlink(self::ARCHIVE); } } ================================================ FILE: src/ZipArchive/ZipArchiveException.php ================================================ > /etc/ssh/sshd_config KexAlgorithms curve25519-sha256 Ciphers aes256-gcm@openssh.com MACs hmac-sha2-256-etm@openssh.com HostKeyAlgorithms ssh-ed25519 EOF ================================================ FILE: test_files/sftp/unknown.key ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEAxZQy1FnuqhoZsiqvFanN5tZRL6GVeCBeoLroGBEggJYVDLw2 nPaXCDOaDoVzs1PkLoKhttA6g4l4eHZgDL1b6QTxRnDbkdOxKJHGEapk+WZk23yq QmU4thDdmgj0clJfS3aTbF7hGWpxrv9zB5saWwgWB/fBeYXu+kXuuO1UJF+F0QGt 6TVVMc6o3NaI7Cq1qoMvt7XGN5LegwfNWRGxaKTL1yn7pkljhkPLOoGedBemieya aQhV+dwzOVeBAXGKSmtiUwto8OMYl/suptMdo+91cLMRJkgXNQ5gA+4niqja5HGW 2Dw8vXja2Spp4+gWWh5OpXwd2pNnJScNINL30wIDAQABAoIBAHzBD7s/sdAcPN9f zj+qgUVhS8/8gilgnv90JPqVTeWDXnU1HnLLzR+znXHP1/eCYBDyEPQi1N+bXMML U6iXpEIlCcfFmQ6iETmhmeQrqChF/CcOt17HFSD400PgpaDN3DgE/h8uZYmryW6L A3HpAKI8H9UWHkcCR5wlrg98Y2W3AacHMwRG8da7QxLcGd10+1lFLuPXTu7LwDf0 u/+Jq9IPwm8WemGiuuOEQ06r7y1s929pA7fWSsn8DlVBIFnPHCtGKpiDfAyEHmCh /LUU6Z5Cl9186sGohu5hWUA+qTMAHw9IR0TbWxF+tAEX8qL5z/qH/surRvzfeRCi BooQB4ECgYEA99xz7AWMUzu6J4FSm29M2AS989Yz0XAgGIYs69pxoaRR5azRffY+ F6wjBpeSI5BElu1DAublhv5hpCgdcw6X2EfMnKvP1jtSbv+DOdR0p80LVAnG7tb3 stSgNwMsGdBpTCghHKUvOUGDU3D3c9O8jCrrJd9Unu4gj2/xbVuC45MCgYEAzBER XeuKuwrN0HmLCZ7AJ1aq7A60UKJjMicdXD5n95ePwHQ32s+hmrcGfUOjcZoWc1Dp yZaZvqFDYu52ZJ1QbNZqjBQJ8sjERSxNlV8DZe1I3ohXvsRQ4I6g8/HRgSl0CVqg +LeJA7n18+SrhhCWhY+dggV1m4qCNcWI7FCQwsECgYEAw+V7xT35U0twbIq8lFba QB03WEGiwNRCub9KP7ptdtjdVY5KIKj/GEyXfj1LZko+u56YCPIe1Ju25jxCUk5l Wq4cnHL6mBJYq5vMxmcRMBJR8sCrdtd1++QrIG+kal6a6nMJAI/ZjAIoXkl5ehUN /yZopY0mX1pLZ7KM+OaLw3sCgYEAvaACllbA9Gvmspmu5IKbJjL34yEK137+VGVa eBQZgk5ZK0oTeQXFssHuisomf/Lid8exZzzFowmxV6YlZ/ty96ALJB2e3PdIwsqX UX0X6EgllXv2pXNBgFmpIOYNe0ts4yBPQq8x57+O2FMePBb/+B5rC55NGfsMYjEr ugRncEECgYB9Wrnp4/WpTwcSJTJXWB+FJaMmcXqbFLk/AI1HbRXJFmR/ssK1gugQ Y3vevvRMSJkYkd1Zmbp9x8ZAGKzR/yeT8lQ6gY/xYq1fYLKKKtzHdwTMCVIVDgSf /v9Y8mLylcgr7ZjEo8xMTnb98ozSSFPBn2jq8c31AsQxXaIMK0BE7g== -----END RSA PRIVATE KEY----- ================================================ FILE: test_files/sftp/users.conf ================================================ foo:pass:1001:100:upload bar:pass:1001:100:upload ================================================ FILE: test_files/toxiproxy/toxiproxy.json ================================================ [ { "name": "sftp", "listen": "[::]:8222", "upstream": "sftp:22", "enabled": true }, { "name": "ftp", "listen": "[::]:8121", "upstream": "ftp:21", "enabled": true }, { "name": "ftpd", "listen": "[::]:8122", "upstream": "ftpd:21", "enabled": true } ] ================================================ FILE: test_files/wait_for_ftp.php ================================================ 'localhost', 'port' => (int) ($argv[1] ?? 2122), 'root' => '/', 'username' => 'foo', 'password' => 'pass', ]); $provider = new FtpConnectionProvider(); $start = time(); $connected = false; while (time() - $start < 60) { try { $provider->createConnection($options); $connected = true; break; } catch (Throwable $exception) { if (time() - $start < 30) { fwrite(STDOUT, "Exception while trying to connect:'\n"); fwrite(STDOUT, (string) $exception); fwrite(STDOUT, "\n\n"); } usleep(10000); } } if ( ! $connected) { fwrite(STDERR, "Unable to start FTP server.\n"); exit(1); } fwrite(STDOUT, "Detected FTP server successfully.\n"); ================================================ FILE: test_files/wait_for_sftp.php ================================================ 'localhost', 'username' => 'foo', 'password' => 'pass', 'port' => 2222, ] ); $start = time(); $connected = false; while (time() - $start < 60) { try { $connectionProvider->provideConnection(); $connected = true; break; } catch (Throwable $exception) { echo($exception); usleep(10000); } } if ( ! $connected) { fwrite(STDERR, "Unable to start SFTP server.\n"); exit(1); } fwrite(STDOUT, "Detected SFTP server successfully.\n");