Skip to content

Commit 9187312

Browse files
fix: address review feedback on the existing-asset overwrite check
- 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.
1 parent d23b52e commit 9187312

4 files changed

Lines changed: 121 additions & 15 deletions

File tree

‎php/class-media.php‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,27 @@ function_exists( 'wp_get_original_image_path' )
495495
return $file_size;
496496
}
497497

498+
/**
499+
* Get the local file path used to upload an attachment.
500+
*
501+
* Mirrors the file resolution in Connect\Api::upload(): the unscaled original when
502+
* `cloudinary_use_original_image` allows it, the attached file otherwise -- e.g. the
503+
* `-scaled` copy WordPress creates for images over `big_image_size_threshold`.
504+
*
505+
* @param int $attachment_id The attachment ID.
506+
*
507+
* @return string
508+
*/
509+
public function get_upload_file_path( $attachment_id ) {
510+
/** This filter is documented in php/connect/class-api.php */
511+
$use_original = apply_filters( 'cloudinary_use_original_image', true, $attachment_id );
512+
if ( $use_original && function_exists( 'wp_get_original_image_path' ) && wp_attachment_is_image( $attachment_id ) ) {
513+
return wp_get_original_image_path( $attachment_id );
514+
}
515+
516+
return get_attached_file( $attachment_id );
517+
}
518+
498519
/**
499520
* Get the Cloudinary delivery type.
500521
*

‎php/connect/class-api.php‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -560,12 +560,7 @@ public function upload( $attachment_id, $args, $headers = array(), $try_remote =
560560
} else {
561561
// We should have the file in args at this point, but if the transient was set, it will be defaulting here.
562562
if ( empty( $args['file'] ) ) {
563-
if ( wp_attachment_is_image( $attachment_id ) ) {
564-
$get_path_func = $use_original && function_exists( 'wp_get_original_image_path' ) ? 'wp_get_original_image_path' : 'get_attached_file';
565-
$args['file'] = call_user_func( $get_path_func, $attachment_id );
566-
} else {
567-
$args['file'] = get_attached_file( $attachment_id );
568-
}
563+
$args['file'] = $this->media->get_upload_file_path( $attachment_id );
569564
}
570565
// Headers indicate chunked upload.
571566
if ( empty( $headers ) && file_exists( $args['file'] ) ) {

‎php/sync/class-upload-sync.php‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -391,8 +391,12 @@ function ( $is_synced, $post_id ) use ( $attachment_id ) {
391391
* Check whether a Cloudinary "existing" asset is likely this attachment's own local file.
392392
*
393393
* Used to tell apart an orphan left by this same attachment's previously interrupted upload
394-
* (safe to overwrite) from an unrelated asset that happens to share the same derived public
395-
* ID, e.g. WordPress reusing a filename across months (must not be overwritten).
394+
* of the default (non "folder"/"cloud_name") sync type (safe to overwrite) from an unrelated
395+
* asset that happens to share the same derived public ID, e.g. WordPress reusing a filename
396+
* across months (must not be overwritten). Only called once a public_id is unrecorded, so in
397+
* practice this only ever runs for that default sync type; the other types always have one.
398+
*
399+
* @internal Reachable for testing; not intended to be called from outside this class.
396400
*
397401
* @param int $attachment_id The attachment ID.
398402
* @param array $result The Cloudinary upload result.
@@ -401,14 +405,23 @@ function ( $is_synced, $post_id ) use ( $attachment_id ) {
401405
*/
402406
public function is_matching_existing_asset( $attachment_id, $result ) {
403407
if ( empty( $result['bytes'] ) ) {
408+
Utils::log(
409+
sprintf( 'Cloudinary upload result for attachment %d has no "bytes" field; treating as a non-matching asset.', $attachment_id ),
410+
'upload-sync-existing-asset-check'
411+
);
412+
404413
return false;
405414
}
406-
$file = get_attached_file( $attachment_id );
415+
$file = $this->media->get_upload_file_path( $attachment_id );
407416
if ( empty( $file ) || ! file_exists( $file ) ) {
408417
return false;
409418
}
419+
if ( (int) filesize( $file ) !== (int) $result['bytes'] ) {
420+
return false;
421+
}
410422

411-
return (int) filesize( $file ) === (int) $result['bytes'];
423+
// Bytes alone can coincide between unrelated files; confirm with the content hash when available.
424+
return empty( $result['etag'] ) || md5_file( $file ) === $result['etag'];
412425
}
413426

414427
/**

‎tests/phpunit/tests/test-upload-sync.php‎

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,25 @@ public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
5050
}
5151

5252
/**
53-
* Build an Upload_Sync instance.
53+
* Build a fully wired Upload_Sync instance.
5454
*
55-
* is_matching_existing_asset() only calls core get_attached_file()/filesize(), never touches
56-
* $media/$sync/$connect, so the component doesn't need setup() to have wired those up.
55+
* is_matching_existing_asset() reads the upload file path through $media, so setup() needs
56+
* to have run to wire it -- the real Media component, already initialised by the plugin
57+
* bootstrap, is reused rather than stubbed.
5758
*
5859
* @return Upload_Sync
5960
*/
6061
protected function get_upload_sync() {
61-
return new Upload_Sync( \Cloudinary\get_plugin_instance() );
62+
$upload_sync = new Upload_Sync( \Cloudinary\get_plugin_instance() );
63+
$upload_sync->setup();
64+
65+
return $upload_sync;
6266
}
6367

6468
/**
6569
* An existing asset whose byte size matches the local file is treated as this attachment's
66-
* own orphaned upload, so it's safe to overwrite.
70+
* own orphaned upload, so it's safe to overwrite. No etag in the result falls back to the
71+
* byte comparison alone.
6772
*
6873
* @return void
6974
*/
@@ -115,4 +120,76 @@ public function test_does_not_match_when_the_attachment_has_no_local_file() {
115120
$this->get_upload_sync()->is_matching_existing_asset( $post_id, $result )
116121
);
117122
}
123+
124+
/**
125+
* Matching bytes plus a matching etag (the MD5 of the stored asset) confirms the content
126+
* itself, not just its size.
127+
*
128+
* @return void
129+
*/
130+
public function test_matches_when_bytes_and_etag_both_match() {
131+
$result = array(
132+
'bytes' => self::$attachment_bytes,
133+
'etag' => md5_file( get_attached_file( self::$attachment_id ) ),
134+
);
135+
136+
$this->assertTrue(
137+
$this->get_upload_sync()->is_matching_existing_asset( self::$attachment_id, $result )
138+
);
139+
}
140+
141+
/**
142+
* A byte size that coincidentally matches an unrelated file must not be enough on its own
143+
* once an etag is available to rule it out.
144+
*
145+
* @return void
146+
*/
147+
public function test_does_not_match_when_bytes_match_but_etag_differs() {
148+
$result = array(
149+
'bytes' => self::$attachment_bytes,
150+
'etag' => 'not-the-real-hash',
151+
);
152+
153+
$this->assertFalse(
154+
$this->get_upload_sync()->is_matching_existing_asset( self::$attachment_id, $result )
155+
);
156+
}
157+
158+
/**
159+
* Cloudinary uploads the unscaled original for a "-scaled" image (the file WordPress
160+
* attaches for images over big_image_size_threshold is a downsized copy, not what was
161+
* actually sent), so the check must compare against that original, not the attached file.
162+
*
163+
* @return void
164+
*/
165+
public function test_matches_using_the_unscaled_original_for_a_scaled_image() {
166+
$id = self::factory()->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
167+
168+
$original_file = get_attached_file( $id );
169+
$scaled_file = dirname( $original_file ) . '/canola-scaled.jpg';
170+
171+
// Stand in for the "-scaled" file WordPress would attach: same starting bytes, padded
172+
// so its size provably differs from the original left alongside it.
173+
copy( $original_file, $scaled_file );
174+
file_put_contents( $scaled_file, file_get_contents( $scaled_file ) . 'padding' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
175+
update_attached_file( $id, $scaled_file );
176+
177+
$metadata = wp_get_attachment_metadata( $id );
178+
$metadata['original_image'] = wp_basename( $original_file );
179+
wp_update_attachment_metadata( $id, $metadata );
180+
181+
$original_bytes = filesize( $original_file );
182+
$scaled_bytes = filesize( $scaled_file );
183+
184+
$this->assertNotSame( $original_bytes, $scaled_bytes, 'Fixture files must differ in size for this test to be meaningful.' );
185+
186+
// Cloudinary was sent the original -- its bytes must be what's compared against.
187+
$this->assertTrue(
188+
$this->get_upload_sync()->is_matching_existing_asset( $id, array( 'bytes' => $original_bytes ) )
189+
);
190+
// The attached (scaled) file's size is not what was actually uploaded.
191+
$this->assertFalse(
192+
$this->get_upload_sync()->is_matching_existing_asset( $id, array( 'bytes' => $scaled_bytes ) )
193+
);
194+
}
118195
}

0 commit comments

Comments
 (0)