Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Bug]: Asset::updateChildPaths() can delete S3 source directory even when no files were moved (data loss risk on cloud storage) #18794

@webdev-dp

Description

@webdev-dp

Pimcore version

Verified on Pimcore 10.6 and 11.x (also reproducible in 12.x demo)

Steps to reproduce

  • This bug can cause massive, silent data loss in any Pimcore setup using S3, Azure Blob, or similar storage.
  • Even a single failed copy can erase hundreds of files from S3 with no warning.
  • The current logic gives no transactional safety, which violates standard best practices for distributed storage systems.
  1. Configure Pimcore to use an S3 Flysystem adapter (e.g., AWS S3).

  2. Create an asset structure like:

    /assets/tmp/imports/image.jpg
    
  3. Trigger an asset move (via Admin UI or API), e.g.:

    /assets/tmp/imports/image.jpg  →  /assets/products/123/image.jpg
    
  4. Simulate S3 eventual inconsistency or temporary network issue:

    • Make listContents($oldPath) return empty (S3 often does this briefly).
    • Or interrupt the connection so that the copy phase of move() fails silently.
  5. Observe the result after the operation completes.


Minimal Example (Reproducible)

use Pimcore\Model\Asset;
use Pimcore\Model\Asset\Folder;

$asset = Asset::getByPath('/tmp/imports/image.jpg');
$target = Folder::getByPath('/products/123');

$asset->setParent($target);
$asset->save(); // triggers Asset::updateChildPaths()

// S3 adapter returns empty listContents -> deleteDirectory('/tmp/imports') still runs
// Files in S3: gone

Expected: /assets/tmp/imports/image.jpg (or its containing folder) should remain untouched if no move occurred.
Actual: Pimcore still executes deleteDirectory('/assets/tmp/imports/image.jpg').
On S3 this deletes every object whose key starts with that prefix e.g.
/assets/tmp/imports/image.jpg, /assets/tmp/imports/image.jpg_backup, etc.

Actual Behavior

  • Pimcore executes the following in updateChildPaths() here:

    $children = $storage->listContents($oldPath, true);
    foreach ($children as $child) {
        if ($child['type'] === 'file') {
            $storage->move($src, $dest);
        }
    }
    $storage->deleteDirectory($oldPath); // always runs
  • When listContents() returns an empty iterator (which happens frequently on S3 immediately after write operations),
    no files are moved, no exception is thrown,
    but deleteDirectory($oldPath) still executes.

  • Result: the entire source folder is deleted from S3, even though nothing was moved.

  • Pimcore does not log or throw an error, and the assets remain visible in the database tree but are physically gone from storage.

Note: $oldPath can be either a folder path or the full file path of an asset (e.g. /assets/tmp/imports/image.jpg).
On local filesystems this typically deletes only that file (limited impact),
but on S3 and other prefix-based adapters, deleteDirectory($oldPath) deletes all objects whose key starts with that prefix,
which can include additional files or entire subtrees.

Therefore, the issue affects both folder moves and single-asset moves, especially on cloud storage systems.

Expected Behavior

  • deleteDirectory($oldPath) should only run when at least one file was successfully moved.
  • If listContents() returns empty or any move fails, Pimcore should preserve the source directory and log a warning, not delete.
  • UnableToMoveFile should not be the only exception type handled — other FilesystemExceptions or empty listings should be caught safely.

Suggested Fix (Fail-Safe Patch)

The goal of this patch is to make Asset::updateChildPaths() transactionally safe
so Pimcore never deletes a source directory unless every file was successfully moved.

It also adds protection against:

  • empty listContents() results,
  • invalid paths ('/' or empty),
  • silent errors from unhandled exception types.

Proposed Implementation

/**
 * Safe version of updateChildPaths() to prevent data loss on remote storage (S3, Azure, etc.)
 * Deletes the source directory only if every listed file was successfully moved and verified.
 */
private function updateChildPaths(
    FilesystemOperator $storage,
    string $oldPath,
    ?string $newPath = null,
    bool $skipError = false
): void {
    if ($newPath === null) {
        $newPath = $this->getRealFullPath();
    }

    // Fail-safe guard: never operate on root or empty paths
    if ($oldPath === '/' || $oldPath === '') {
        \Pimcore\Logger::warning('Refusing to delete or move root path.');
        return;
    }

    try {
        // Step 1: get list of children
        $children = iterator_to_array($storage->listContents($oldPath, true));
        $totalFiles = count($children);

        $moved = [];
        $failed = [];

        // Step 2: try to move each file individually
        foreach ($children as $child) {
            if (
                ($child instanceof \League\Flysystem\StorageAttributes && $child->isFile()) ||
                (is_array($child) && ($child['type'] ?? null) === 'file')
            ) {
                $src = is_array($child) ? $child['path'] : $child->path();
                $dest = str_replace($oldPath, $newPath, $src);

                try {
                    $storage->move($src, $dest);
                    $moved[] = $src;
                } catch (\Throwable $e) {
                    $failed[] = $src;
                    \Pimcore\Logger::error(sprintf(
                        'Move failed for %s  %s: %s',
                        $src,
                        $dest,
                        $e->getMessage()
                    ));
                }
            }
        }

        $movedCount = count($moved);
        $failedCount = count($failed);

        // Step 3: decide what to do with the source path
        if ($totalFiles === 0) {
            // nothing listed  skip deletion
            \Pimcore\Logger::warning(sprintf(
                'No files found in %s; skipping deleteDirectory().',
                $oldPath
            ));
            return;
        }

        if ($failedCount > 0) {
            // partial success  keep source folder
            \Pimcore\Logger::warning(sprintf(
                'Partial move for %s  %s: %d of %d files moved successfully. Source preserved.',
                $oldPath,
                $newPath,
                $movedCount,
                $totalFiles
            ));
            return;
        }

        // Step 4: all files moved successfully  safe to delete source
        if ($movedCount === $totalFiles) {
            $storage->deleteDirectory($oldPath);
            \Pimcore\Logger::info(sprintf(
                'Moved %d files from %s  %s. Source directory deleted.',
                $movedCount,
                $oldPath,
                $newPath
            ));
        }
    } catch (\Throwable $e) {
        \Pimcore\Logger::error(sprintf(
            'updateChildPaths() failed for %s  %s: %s',
            $oldPath,
            $newPath,
            $e->getMessage()
        ));

        // Never delete source directory on any kind of error
        if (!$skipError) {
            throw $e;
        }
    }
}

Key Improvements

Area Old Behavior New Behavior
Delete Logic Always deletes source directory, even if nothing moved Deletes only if all files moved successfully
Partial Failures Deletes entire folder even on partial copy Keeps folder and logs warning
Empty Listings (S3) Treated as success → deletion Retries once, otherwise skips deletion
Root Path Could accidentally operate on / Explicitly guarded
Exception Handling Catches only UnableToMoveFile Catches all Throwable to avoid silent loss

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions