Commit b9d5c8fce1d for woocommerce
commit b9d5c8fce1d016d0c134d6474d9e655903db89e3
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Tue Aug 25 16:39:38 2026 +0300
Overwrite the combined translation file when publishing it fails (#67957)
* Overwrite the combined translation file when publishing it fails
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Delete the translation temp file as a file, not a directory
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Remove the combined translation file when an in-place write truncates it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Cover the SSH2 move that removes the destination before failing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-wooairr-31-stale-combined-translations b/plugins/woocommerce/changelog/fix-wooairr-31-stale-combined-translations
new file mode 100644
index 00000000000..688a831266b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooairr-31-stale-combined-translations
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Same-cycle regression from #67548, never present in a stable release; a Fix entry would describe a bug no 11.1.x upgrader experienced.
diff --git a/plugins/woocommerce/src/Internal/Admin/Translations.php b/plugins/woocommerce/src/Internal/Admin/Translations.php
index 6ee3cbdc7c1..d2c15e6c3b4 100644
--- a/plugins/woocommerce/src/Internal/Admin/Translations.php
+++ b/plugins/woocommerce/src/Internal/Admin/Translations.php
@@ -245,28 +245,47 @@ class Translations {
$cache_filename = $this->get_combined_translation_filename( $plugin_domain, $locale );
$chunk_translations_json = wp_json_encode( $translations_from_chunks );
- // Publish via a temp file so readers never observe a partial write.
+ // Publish via a temp file so readers do not observe a partial write; the fallback below
+ // gives that up rather than leave the previous pack's file in place.
$temp_path = $language_dir . $cache_filename . '.' . wp_generate_password( 12, false ) . '.tmp';
$cache_path = $language_dir . $cache_filename;
+ $published = false;
- if ( ! $wp_filesystem->put_contents( $temp_path, $chunk_translations_json ) ) {
- $wp_filesystem->delete( $temp_path );
- return;
+ if ( $wp_filesystem->put_contents( $temp_path, $chunk_translations_json ) ) {
+ // Not WP_Filesystem_Direct::move(): it deletes the destination before renaming.
+ if ( 'direct' === get_filesystem_method() ) {
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replace; failure handled below.
+ $published = @rename( $temp_path, $cache_path );
+ } else {
+ // Remote filesystems: overwrite only when refreshing an existing file.
+ $published = $wp_filesystem->move( $temp_path, $cache_path, $wp_filesystem->exists( $cache_path ) );
+ }
}
- // Not WP_Filesystem_Direct::move(): it deletes the destination before renaming.
- if ( 'direct' === get_filesystem_method() ) {
- // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replace; failure handled below.
- if ( ! @rename( $temp_path, $cache_path ) ) {
- $wp_filesystem->delete( $temp_path );
+ if ( ! $published ) {
+ /*
+ * Reached when the temp file could not be written, and when publishing it failed: the
+ * FTP filesystems ignore the $overwrite argument and return ftp_rename() as-is, so
+ * servers that refuse to rename onto an existing path end up here. Without an in-place
+ * overwrite the previous language pack's file would survive, and nothing would ever
+ * replace it: the on-demand rebuild only runs when the file is missing, and never on a
+ * remote filesystem.
+ */
+ if ( ! $wp_filesystem->put_contents( $cache_path, $chunk_translations_json ) ) {
+ /*
+ * An in-place write truncates the file before it can fail, so what survives may be
+ * invalid JSON. A missing file recovers on its own - the rebuild triggers on it and
+ * readers fall back to their own translation file - while a corrupt one is served
+ * as-is forever.
+ */
+ $leftover = $wp_filesystem->exists( $cache_path ) ? $wp_filesystem->get_contents( $cache_path ) : false;
+ if ( false !== $leftover && null === json_decode( $leftover, true ) ) {
+ $wp_filesystem->delete( $cache_path, false, 'f' );
+ }
}
- return;
- }
- // Remote filesystems: overwrite only when refreshing an existing file.
- $overwrite = $wp_filesystem->exists( $cache_path );
- if ( ! $wp_filesystem->move( $temp_path, $cache_path, $overwrite ) ) {
- $wp_filesystem->delete( $temp_path );
+ // Without the type, FTPext::delete() falls through to ftp_rmdir() when the file is absent.
+ $wp_filesystem->delete( $temp_path, false, 'f' );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
index a07ad03476d..ce054d580ba 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/TranslationsTest.php
@@ -80,6 +80,24 @@ class TranslationsTest extends WC_Unit_Test_Case {
return 'de_DE';
}
+ /**
+ * Forces get_filesystem_method() to report a remote filesystem.
+ *
+ * @return string Filesystem method.
+ */
+ public function force_ftpext_filesystem() {
+ return 'ftpext';
+ }
+
+ /**
+ * Forces get_filesystem_method() to report an SSH2 filesystem.
+ *
+ * @return string Filesystem method.
+ */
+ public function force_ssh2_filesystem() {
+ return 'ssh2';
+ }
+
/**
* Writes a chunk translation JSON file in the official language pack format.
*
@@ -195,6 +213,236 @@ class TranslationsTest extends WC_Unit_Test_Case {
$this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after replacing the combined file' );
}
+ /**
+ * @testdox Should overwrite the combined file in place when a remote filesystem refuses to move onto it.
+ */
+ public function test_replaces_existing_combined_file_when_remote_move_fails(): void {
+ $combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+ file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ $this->create_chunk_json(
+ 'dddddddddddddddddddddddddddddddd',
+ WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+ array( 'Show' => array( 'Anzeigen' ) )
+ );
+
+ if ( ! function_exists( 'get_filesystem_method' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
+ \WP_Filesystem();
+
+ $previous_filesystem = $GLOBALS['wp_filesystem'];
+
+ // Mirrors WP_Filesystem_FTPext against a server that refuses to rename onto an existing path.
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+ $GLOBALS['wp_filesystem'] = new class( null ) extends \WP_Filesystem_Direct {
+ /**
+ * Always reports the move as failed.
+ *
+ * @param string $source Path to the source file.
+ * @param string $destination Path to the destination file.
+ * @param bool $overwrite Whether to overwrite the destination.
+ * @return bool Always false.
+ */
+ public function move( $source, $destination, $overwrite = false ) {
+ return false;
+ }
+ };
+ add_filter( 'filesystem_method', array( $this, 'force_ftpext_filesystem' ), PHP_INT_MAX );
+
+ try {
+ $build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+ $build->setAccessible( true );
+ $build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+ } finally {
+ remove_filter( 'filesystem_method', array( $this, 'force_ftpext_filesystem' ), PHP_INT_MAX );
+ $GLOBALS['wp_filesystem'] = $previous_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the real filesystem.
+ }
+
+ $data = json_decode( file_get_contents( $combined ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $this->assertArrayNotHasKey( 'stale', $data, 'The previous language pack file should not survive a failed move' );
+ $this->assertSame( array( 'Anzeigen' ), $data['locale_data']['messages']['Show'], 'The refreshed translations should be written in place instead' );
+ $this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after the fallback write' );
+ }
+
+ /**
+ * @testdox Should overwrite the combined file in place when the temp file cannot be written.
+ */
+ public function test_replaces_existing_combined_file_when_temp_write_fails(): void {
+ $combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+ file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ $this->create_chunk_json(
+ 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
+ WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+ array( 'Show' => array( 'Anzeigen' ) )
+ );
+
+ if ( ! function_exists( 'get_filesystem_method' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
+ \WP_Filesystem();
+
+ $previous_filesystem = $GLOBALS['wp_filesystem'];
+
+ // Mirrors a language directory that allows writing its existing files but not creating new ones.
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+ $GLOBALS['wp_filesystem'] = new class( null ) extends \WP_Filesystem_Direct {
+ /**
+ * Rejects writes to the temp file, allowing every other write through.
+ *
+ * @param string $file Path to the file.
+ * @param string $contents Contents to write.
+ * @param int|false $mode Optional file permissions.
+ * @return bool Whether the contents were written.
+ */
+ public function put_contents( $file, $contents, $mode = false ) {
+ if ( str_ends_with( $file, '.tmp' ) ) {
+ return false;
+ }
+ return parent::put_contents( $file, $contents, $mode );
+ }
+ };
+
+ try {
+ $build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+ $build->setAccessible( true );
+ $build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+ } finally {
+ $GLOBALS['wp_filesystem'] = $previous_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the real filesystem.
+ }
+
+ $data = json_decode( file_get_contents( $combined ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $this->assertArrayNotHasKey( 'stale', $data, 'The previous language pack file should not survive a failed temp write' );
+ $this->assertSame( array( 'Anzeigen' ), $data['locale_data']['messages']['Show'], 'The refreshed translations should be written in place instead' );
+ $this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after the fallback write' );
+ }
+
+ /**
+ * @testdox Should recreate the combined file when a remote move removes it before failing.
+ */
+ public function test_recreates_combined_file_when_remote_move_destroys_it(): void {
+ $combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+ file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ $this->create_chunk_json(
+ 'ffffffffffffffffffffffffffffffff',
+ WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+ array( 'Show' => array( 'Anzeigen' ) )
+ );
+
+ if ( ! function_exists( 'get_filesystem_method' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
+ \WP_Filesystem();
+
+ $previous_filesystem = $GLOBALS['wp_filesystem'];
+
+ // Mirrors WP_Filesystem_SSH2::move(), which removes the destination before renaming onto it.
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+ $GLOBALS['wp_filesystem'] = new class( null ) extends \WP_Filesystem_Direct {
+ /**
+ * Removes the destination, then reports the move as failed.
+ *
+ * @param string $source Path to the source file.
+ * @param string $destination Path to the destination file.
+ * @param bool $overwrite Whether to overwrite the destination.
+ * @return bool Always false.
+ */
+ public function move( $source, $destination, $overwrite = false ) {
+ $this->delete( $destination, false, 'f' );
+ return false;
+ }
+ };
+ add_filter( 'filesystem_method', array( $this, 'force_ssh2_filesystem' ), PHP_INT_MAX );
+
+ try {
+ $build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+ $build->setAccessible( true );
+ $build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+ } finally {
+ remove_filter( 'filesystem_method', array( $this, 'force_ssh2_filesystem' ), PHP_INT_MAX );
+ $GLOBALS['wp_filesystem'] = $previous_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the real filesystem.
+ }
+
+ $this->assertFileExists( $combined, 'A move that removes the destination should not leave the site without a combined file' );
+ $data = json_decode( file_get_contents( $combined ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $this->assertSame( array( 'Anzeigen' ), $data['locale_data']['messages']['Show'], 'The recreated file should contain the rebuilt translations' );
+ }
+
+ /**
+ * @testdox Should remove the combined file when the in-place write leaves it truncated.
+ */
+ public function test_removes_combined_file_when_fallback_write_is_truncated(): void {
+ $combined = $this->lang_dir . 'woocommerce-de_DE-' . WC_ADMIN_APP . '.json';
+ file_put_contents( $combined, '{"stale":true}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+ $this->create_chunk_json(
+ 'gggggggggggggggggggggggggggggggg',
+ WC_ADMIN_DIST_JS_FOLDER . 'app/index.js',
+ array( 'Show' => array( 'Anzeigen' ) )
+ );
+
+ if ( ! function_exists( 'get_filesystem_method' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ }
+ \WP_Filesystem();
+
+ $previous_filesystem = $GLOBALS['wp_filesystem'];
+
+ // Mirrors WP_Filesystem_Direct::put_contents() truncating the file before a short write fails.
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+ $stub = new class( null ) extends \WP_Filesystem_Direct {
+ /**
+ * Path the truncated write is simulated for.
+ *
+ * @var string
+ */
+ public $truncate_path = '';
+
+ /**
+ * Always reports the move as failed.
+ *
+ * @param string $source Path to the source file.
+ * @param string $destination Path to the destination file.
+ * @param bool $overwrite Whether to overwrite the destination.
+ * @return bool Always false.
+ */
+ public function move( $source, $destination, $overwrite = false ) {
+ return false;
+ }
+
+ /**
+ * Writes a partial payload and reports failure for the tracked path.
+ *
+ * @param string $file Path to the file.
+ * @param string $contents Contents to write.
+ * @param int|false $mode Optional file permissions.
+ * @return bool Whether the contents were written.
+ */
+ public function put_contents( $file, $contents, $mode = false ) {
+ if ( $file === $this->truncate_path ) {
+ parent::put_contents( $file, '{"locale_data', $mode );
+ return false;
+ }
+ return parent::put_contents( $file, $contents, $mode );
+ }
+ };
+ $stub->truncate_path = $combined;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Stub filesystem, restored below.
+ $GLOBALS['wp_filesystem'] = $stub;
+ // The direct branch renames outside the filesystem object, so route publishing through move().
+ add_filter( 'filesystem_method', array( $this, 'force_ftpext_filesystem' ), PHP_INT_MAX );
+
+ try {
+ $build = new \ReflectionMethod( Translations::class, 'build_and_save_translations' );
+ $build->setAccessible( true );
+ $build->invoke( $this->sut, $this->lang_dir, 'woocommerce', 'de_DE' );
+ } finally {
+ remove_filter( 'filesystem_method', array( $this, 'force_ftpext_filesystem' ), PHP_INT_MAX );
+ $GLOBALS['wp_filesystem'] = $previous_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the real filesystem.
+ }
+
+ $this->assertFileDoesNotExist( $combined, 'A truncated write should not be left behind for readers to parse' );
+ $this->assertSame( array(), glob( $this->lang_dir . '*.tmp' ), 'No temporary files should be left behind after a truncated write' );
+ }
+
/**
* @testdox Should fall back to the original file when nothing can be rebuilt.
*/