Fix: don't overwrite an unrelated Cloudinary asset on public ID collision - #1252
Fix: don't overwrite an unrelated Cloudinary asset on public ID collision#1252gabrielcld2 wants to merge 2 commits into
Conversation
…hment's own orphan PR #1182 treated any attachment with no locally-saved public_id as proof that a colliding Cloudinary asset was an orphan from that same attachment's own crashed upload, and overwrote it. That's wrong for a brand new attachment too, since it also has no public_id yet -- so a fresh upload that happens to derive the same public_id as an unrelated, older asset (e.g. WordPress reusing a filename across months) silently clobbers that older asset. Only take the overwrite path when the existing asset's byte size also matches the local file, which is true for a genuine orphan of this attachment's own upload but not for an unrelated collision. Fixes #1241
| if ( empty( $result['bytes'] ) ) { | ||
| return false; | ||
| } | ||
| $file = get_attached_file( $attachment_id ); |
There was a problem hiding this comment.
Blocker: this does not resolve the same file that the upload actually sent, so the #1182 recovery path stops working for large images.
Api::upload() applies the cloudinary_use_original_image filter (default true) and then uses wp_get_original_image_path() for images:
// php/connect/class-api.php:529-534, 564-565
$use_original = apply_filters( 'cloudinary_use_original_image', true, $attachment_id );
...
$get_path_func = $use_original && function_exists( 'wp_get_original_image_path' ) ? 'wp_get_original_image_path' : 'get_attached_file';Core diverges the two whenever original_image is set in the attachment metadata: wp_get_original_image_path() returns the untouched original, get_attached_file() returns the -scaled file.
So for any image above big_image_size_threshold (2560px default, which covers most phone and camera uploads) this compares the -scaled bytes against the original's bytes. They never match, is_matching_existing_asset() returns false, and the attachment falls back to the suffix branch. That is exactly the runaway-duplicate behaviour PR #1182 existed to stop.
The QA notes in the description do not catch this because both scenarios use a small image that never gets scaled.
Mirroring the upload's own resolution fixes it:
$use_original = apply_filters( 'cloudinary_use_original_image', true, $attachment_id );
if ( $use_original && function_exists( 'wp_get_original_image_path' ) && wp_attachment_is_image( $attachment_id ) ) {
$file = wp_get_original_image_path( $attachment_id );
} else {
$file = get_attached_file( $attachment_id );
}Media::get_attachment_file_size() (php/class-media.php:475-496) already contains this branch. Worth extracting it into one shared helper rather than duplicating a third copy. Note it caches into _file_size meta, so calling that method directly would feed a possibly stale value into a destructive decision.
Please also re-run QA scenario 2 with an image over 2560px, that is the case that currently fails.
There was a problem hiding this comment.
Fixed in 9187312 — thanks for catching this. is_matching_existing_asset() now calls a new shared Media::get_upload_file_path(), which is the exact same resolution Api::upload() uses (cloudinary_use_original_image filter + wp_get_original_image_path() for images, falling back to get_attached_file()), so both now compare against the same file Api::upload() actually sent. Api::upload()'s own inline branch was replaced with a call to the same helper, so there's one canonical implementation instead of a third copy. Added a test (test_matches_using_the_unscaled_original_for_a_scaled_image) that fakes a scaled/original pair via _wp_attachment_metadata['original_image'] and asserts the check matches on the original's bytes, not the attached (scaled) file's.\n\nRe-running QA scenario 2 with an image over 2560px on this commit — will report back once confirmed.
There was a problem hiding this comment.
Confirmed — re-ran QA scenario 2 with a 3200×1800px, 1.74MB image (large enough to get WordPress's -scaled treatment). The recovered public_id came back unsuffixed (scaled-image-test), so the crash-recovery path now works correctly for scaled images too. Updated the PR description accordingly.
| return false; | ||
| } | ||
|
|
||
| return (int) filesize( $file ) === (int) $result['bytes']; |
There was a problem hiding this comment.
Consider: byte size alone is a weak identity signal, in both directions.
- False positive: two unrelated files that derive the same public ID and happen to have the same byte count still take the overwrite path, and the older asset is still destroyed. That is the exact failure mode this PR exists to prevent, narrowed rather than closed.
- False negative: if the product environment applies an incoming transformation, or a
cloudinary_upload_optionsfilter addstransformation, the stored asset's bytes will not equal the local file's bytes, and the Fix duplicate assets on DB write fails scenarios #1182 recovery silently stops working for that environment.
Cloudinary returns etag in the same response, which is the MD5 of the stored bytes. Gating on bytes first and then confirming with md5_file() closes the false positive completely, and the hash only runs on the collision path, never on a normal upload:
if ( (int) filesize( $file ) !== (int) $result['bytes'] ) {
return false;
}
return empty( $result['etag'] ) || md5_file( $file ) === $result['etag'];There was a problem hiding this comment.
Done in 9187312 — once bytes match, it now confirms with md5_file() against the response's etag, only computed on the collision path as you suggested. Kept it opt-in (empty( $result['etag'] ) ||) rather than required, so a response missing that field still falls back to the byte check instead of always failing closed — the false-negative you flagged (transformation-altered stored bytes) already broke the byte check on its own, so treating a missing etag as inconclusive-in-the-wrong-direction there wouldn't help; it would just add a second silent failure mode. Added two tests covering the etag match/mismatch cases.
| * @return bool | ||
| */ | ||
| public function is_matching_existing_asset( $attachment_id, $result ) { | ||
| if ( empty( $result['bytes'] ) ) { |
There was a problem hiding this comment.
Nit: the fallback direction is right, safe over destructive. But it makes the #1182 fix depend on an undocumented field of the existing response. If that field is ever trimmed, the recovery path dies silently, with no error and no log, and suffixed duplicates quietly come back.
A sync note or debug trace when the check bails out on a missing field would save the next person from bisecting two PRs to work out why.
There was a problem hiding this comment.
Added — 9187312 logs via Utils::log() (the existing debug-report mechanism, see class-delivery.php/class-responsive-breakpoints.php for the same pattern) when the check bails out for a missing bytes field, so a future response shape change shows up in the debug log instead of silently reverting to the old duplicate-per-cycle behavior.
| * | ||
| * @return bool | ||
| */ | ||
| public function is_matching_existing_asset( $attachment_id, $result ) { |
There was a problem hiding this comment.
Nit: public for a method with a single internal caller. Reasonable trade to make it reachable from the test, but it is now plugin API surface that has to keep its signature. An @internal note in the docblock would set the expectation.
Also worth narrowing the docblock's claim of generality: the folder and cloud_name sync types route to Api::copy(), which uploads from a Cloudinary URL, not a local file, and under offload=cld there may be no local file at all. In practice those types only run once a public_id is recorded, so the second clause of the condition short-circuits first and this is never reached, but the docblock reads as though it applies to any upload.
There was a problem hiding this comment.
Both addressed in 9187312: added an @internal note, and narrowed the docblock to note this only actually runs for the default sync type in practice, since folder/cloud_name route through Api::copy() and, as you noted, only ever get here once a public_id already exists (short-circuiting the first clause). Left it public per your call.
- Compare against the same file Api::upload() actually sends, not always the attached file. For images over big_image_size_threshold, get_attached_file() returns the "-scaled" copy while the upload itself sends the unscaled original via wp_get_original_image_path(), so the two sizes never matched and the #1182 crash-recovery path silently stopped working for large images. Extracted the shared resolution into Media::get_upload_file_path(), used by both Api::upload() and the new check, instead of a third inline copy. - Confirm with the response's etag (MD5 of the stored asset) once byte sizes already match, closing the remaining false-positive where two unrelated files coincidentally share a byte count. - Log via Utils::log() when the check bails out for lack of a `bytes` field, so a future API response change doesn't silently resurrect the #1182 duplicate-per-cycle bug. - Mark is_matching_existing_asset() @internal and narrow its docblock to the sync type it actually runs for. Extends the test suite with the scaled-image and etag scenarios.
Summary
_public_id._public_id, not just one recovering from a crashed upload. So a brand new attachment that happens to derive the same public ID as an older, unrelated asset (e.g. WordPress reuses a filename across different months) also takes the overwrite path and silently destroys that older asset.Upload_Sync::is_matching_existing_asset(), that only takes the overwrite path when the conflicting Cloudinary asset is confirmed to be the local file. Same file, lost record (PR Fix duplicate assets on DB write fails scenarios #1182's case) → confirmed match → still safely overwritten. Different file, coincidental public ID collision (this issue) → no match → falls back to the pre-Fix duplicate assets on DB write fails scenarios #1182 suffix behavior instead, so the older asset is preserved.Fixes #1241
Review follow-up
Addressed in a follow-up commit after review:
Api::upload()does (Media::get_upload_file_path(), shared by both), instead of always usingget_attached_file(). That matters for any image overbig_image_size_threshold(2560px default) — WordPress attaches the-scaledcopy but Cloudinary is sent the unscaled original, so comparing against the attached file broke the Fix duplicate assets on DB write fails scenarios #1182 recovery path for large images.etag(MD5 of the stored asset) once byte sizes already match, closing the remaining false-positive where two unrelated files coincidentally share a byte count.Utils::log()when the check bails out for a missingbytesfield, so a future API response change surfaces in the debug log instead of silently reviving the duplicate-per-cycle bug.@internal.QA notes
Scenario 1 — the reported bug (#1241) is fixed
test.jpg. Wait for sync. Note its ID as<A_ID>.<B_ID>. Then force it into "first-ever upload oftest.jpg" state:develop(bug present): both printtest— B silently claimed A's public ID and overwrote its Cloudinary asset.test, B gets a suffixed public ID (e.g.test_<id><random>) — both assets survive independently.Scenario 2 — PR #1182's original crash-recovery fix still works
Use an image larger than 2560px on its longest side for step 1 — that's what exercises the
-scaledfile path the review feedback flagged, and what scenario 2 originally missed.<X_ID>.Both scenarios were run and confirmed against a real Cloudinary account, including scenario 2 re-run with a 3200×1800px/1.74MB image (large enough to trigger WordPress's
-scaledfile, per the review feedback): the recovered public_id came back unsuffixed, confirming the crash-recovery path now also works correctly for scaled images.Automated tests
tests/phpunit/tests/test-upload-sync.phpcoversis_matching_existing_asset(): matching/mismatched bytes, a missingbytesfield, a missing local file, matching/mismatchedetag, and a scaled-image case asserting the check compares against the unscaled original rather than the attached-scaledfile. Run withnpm run env:start && npm run test:unit.