-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Description
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.
-
Configure Pimcore to use an S3 Flysystem adapter (e.g., AWS S3).
-
Create an asset structure like:
/assets/tmp/imports/image.jpg -
Trigger an asset move (via Admin UI or API), e.g.:
/assets/tmp/imports/image.jpg → /assets/products/123/image.jpg -
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.
- Make
-
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: goneExpected: /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,
butdeleteDirectory($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. UnableToMoveFileshould not be the only exception type handled — otherFilesystemExceptions 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 |