Commit 58c8c2e269 for woocommerce

commit 58c8c2e2699fc96c5682f48772678064f23f8f88
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Wed Jan 14 16:38:52 2026 +0100

    Add support for relevant files to the RestApiCache trait (#62768)

diff --git a/plugins/woocommerce/changelog/pr-62768 b/plugins/woocommerce/changelog/pr-62768
new file mode 100644
index 0000000000..beac6e0f6e
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-62768
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add support for relevant files to the RestApiCache trait
diff --git a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
index 3af2eb8d3c..8d468f2cb8 100644
--- a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
+++ b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
@@ -70,6 +70,10 @@ use WP_REST_Response;
  *                         'cache_ttl'      => HOUR_IN_SECONDS,
  *                         // Optional array, defaults to the controller's get_hooks_relevant_to_caching().
  *                         'relevant_hooks'  => array( 'filter_name_1', 'filter_name_2' ),
+ *                         // Optional array, defaults to the controller's get_files_relevant_to_response_caching().
+ *                         // Paths can be absolute or relative to the first directory from
+ *                         // get_allowed_directories_for_file_based_response_caching() (WC_ABSPATH by default).
+ *                         'relevant_files'  => array( 'data/config.json', '/absolute/path/to/file.php' ),
  *                         // Optional bool, defaults to the controller's response_cache_vary_by_user().
  *                         'vary_by_user'    => true,
  *                         // Optional array, defaults to the controller's get_response_headers_to_include_in_caching().
@@ -89,6 +93,9 @@ use WP_REST_Response;
  * - get_default_response_entity_type(): Default entity type for endpoints without explicit config.
  * - response_cache_vary_by_user(): Whether cache should be user-specific.
  * - get_hooks_relevant_to_caching(): Hook names to track for cache invalidation.
+ * - get_files_relevant_to_response_caching(): File paths to track for cache invalidation.
+ * - get_allowed_directories_for_file_based_response_caching(): Directories allowed for file tracking.
+ * - get_file_check_interval_for_response_caching(): How long to cache file modification checks (default 10 minutes).
  * - get_ttl_for_cached_response(): TTL for cached outputs in seconds.
  * - get_response_headers_to_include_in_caching(): Headers to include in cache (false = use exclusion mode).
  * - get_response_headers_to_exclude_from_caching(): Headers to exclude from cache (when in exclusion mode).
@@ -97,6 +104,8 @@ use WP_REST_Response;
  * - Entity versions change (tracked via VersionStringGenerator).
  * - Hook callbacks change
  *   (if the `get_hooks_relevant_to_caching()` call result or the 'relevant_hooks' array isn't empty).
+ * - Tracked files change or are deleted
+ *   (if the `get_files_relevant_to_response_caching()` call result or the 'relevant_files' array isn't empty).
  * - Cached response TTL expires.
  *
  * NOTE: This caching mechanism uses the WordPress cache (wp_cache_* functions).
@@ -131,6 +140,20 @@ trait RestApiCache {
 		'Pragma',
 	);

+	/**
+	 * Cache group for warning suppression (separate from main cache to avoid interference).
+	 *
+	 * @var string
+	 */
+	private static string $warning_cache_group = 'woocommerce_rest_api_cache_warnings';
+
+	/**
+	 * TTL for suppressing duplicate file tracking warnings (1 hour).
+	 *
+	 * @var int
+	 */
+	private static int $file_warning_suppression_ttl = HOUR_IN_SECONDS;
+
 	/**
 	 * The instance of VersionStringGenerator to use, or null if caching is disabled.
 	 *
@@ -184,6 +207,7 @@ trait RestApiCache {
 	 *                           - endpoint_id: string|null (optional friendly identifier for the endpoint).
 	 *                           - cache_ttl: int (defaults to get_ttl_for_cached_response()).
 	 *                           - relevant_hooks: array (defaults to get_hooks_relevant_to_caching()).
+	 *                           - relevant_files: array (defaults to get_files_relevant_to_response_caching()).
 	 *                           - include_headers: array|false (defaults to get_response_headers_to_include_in_caching()).
 	 *                           - exclude_headers: array (defaults to get_response_headers_to_exclude_from_caching()).
 	 * @return callable Wrapped callback.
@@ -217,14 +241,7 @@ trait RestApiCache {
 			return call_user_func( $callback, $request );
 		}

-		$cached_config     = null;
-		$should_skip_cache = ! $this->should_use_cache_for_request( $request );
-		if ( ! $should_skip_cache ) {
-			$cached_config     = $this->build_cache_config( $request, $config );
-			$should_skip_cache = is_null( $cached_config );
-		}
-
-		if ( $should_skip_cache || is_null( $cached_config ) ) {
+		if ( ! $this->should_use_cache_for_request( $request ) ) {
 			$response = call_user_func( $callback, $request );
 			if ( ! is_wp_error( $response ) ) {
 				$response = rest_ensure_response( $response );
@@ -233,6 +250,8 @@ trait RestApiCache {
 			return $response;
 		}

+		$cached_config = $this->build_cache_config( $request, $config );
+
 		$this->is_handling_cached_endpoint = true;

 		if ( $backend_caching_enabled ) {
@@ -286,24 +305,20 @@ trait RestApiCache {
 	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
 	 * @param array                                 $config  Raw configuration array passed to with_cache.
 	 *
-	 * @return array|null Normalized cache config with keys: endpoint_id, entity_type, vary_by_user, cache_ttl, relevant_hooks, include_headers, exclude_headers, cache_key. Returns null if entity type is not available.
+	 * @return array Normalized cache config with keys: endpoint_id, entity_type, vary_by_user, cache_ttl, relevant_hooks, relevant_files, include_headers, exclude_headers, cache_key.
 	 *
-	 * @throws \InvalidArgumentException If include_headers is not false or an array.
+	 * @throws \InvalidArgumentException If entity_type is not provided and no default is available, or if include_headers is not false or an array.
 	 */
-	private function build_cache_config( WP_REST_Request $request, array $config ): ?array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+	private function build_cache_config( WP_REST_Request $request, array $config ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
 		$endpoint_id  = $config['endpoint_id'] ?? null;
 		$entity_type  = $config['entity_type'] ?? $this->get_default_response_entity_type();
 		$vary_by_user = $config['vary_by_user'] ?? $this->response_cache_vary_by_user( $request, $endpoint_id );

 		if ( ! $entity_type ) {
-			$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
-			$legacy_proxy->call_function(
-				'wc_doing_it_wrong',
-				__METHOD__,
-				'No entity type provided and no default entity type available. Skipping cache.',
-				'10.5.0'
+			throw new \InvalidArgumentException(
+				'REST API cache: No entity type provided in with_cache() config and no default entity type available from get_default_response_entity_type(). ' .
+				'Either pass "entity_type" in the config array or override get_default_response_entity_type() in your controller.'
 			);
-			return null;
 		}

 		$include_headers = $config['include_headers'] ?? $this->get_response_headers_to_include_in_caching( $request, $endpoint_id );
@@ -319,6 +334,7 @@ trait RestApiCache {
 			'vary_by_user'    => $vary_by_user,
 			'cache_ttl'       => $config['cache_ttl'] ?? $this->get_ttl_for_cached_response( $request, $endpoint_id ),
 			'relevant_hooks'  => $config['relevant_hooks'] ?? $this->get_hooks_relevant_to_caching( $request, $endpoint_id ),
+			'relevant_files'  => $config['relevant_files'] ?? $this->get_files_relevant_to_response_caching( $request, $endpoint_id ),
 			'include_headers' => $include_headers,
 			'exclude_headers' => $config['exclude_headers'] ?? $this->get_response_headers_to_exclude_from_caching( $request, $endpoint_id ),
 			'cache_key'       => $this->get_key_for_cached_response( $request, $entity_type, $vary_by_user, $endpoint_id ),
@@ -376,6 +392,7 @@ trait RestApiCache {
 				$entity_ids,
 				$cached_config['cache_ttl'],
 				$cached_config['relevant_hooks'],
+				$cached_config['relevant_files'],
 				$cacheable_headers,
 				$etag
 			);
@@ -554,6 +571,62 @@ trait RestApiCache {
 		return array();
 	}

+	/**
+	 * Get the paths of files whose modification affects the response.
+	 *
+	 * All the returned files will be tracked for changes: whenever a response is cached,
+	 * each file's modification time is recorded, and if any file has changed or disappeared
+	 * when the cached response is retrieved, the cache entry will be invalidated.
+	 *
+	 * Paths can be absolute or relative. Relative paths are resolved relative to the first
+	 * directory returned by get_allowed_directories_for_file_based_response_caching().
+	 *
+	 * This can be customized per-endpoint via the config array
+	 * passed to with_cache() ('relevant_files' key).
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param WP_REST_Request<array<string, mixed>> $request     Request object.
+	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
+	 *
+	 * @return array Array of file paths to track.
+	 */
+	protected function get_files_relevant_to_response_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
+		return array();
+	}
+
+	/**
+	 * Get directories allowed for file-based response caching.
+	 *
+	 * Returns an array of directory paths that are allowed to contain files tracked
+	 * for cache invalidation. The first directory in the array is also used as the
+	 * base path for resolving relative file paths.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @return array Array of absolute directory paths.
+	 */
+	protected function get_allowed_directories_for_file_based_response_caching(): array {
+		return defined( 'WC_ABSPATH' ) ? array( WC_ABSPATH ) : array();
+	}
+
+	/**
+	 * Get the interval for caching file modification checks.
+	 *
+	 * To avoid checking file modification times on every request, file checks are cached
+	 * for this interval. During this period, files are assumed to be unchanged.
+	 *
+	 * Override this method to customize the interval. Return 0 to disable caching
+	 * and check files on every request.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @return int Interval in seconds. Default is 10 minutes (600 seconds).
+	 */
+	protected function get_file_check_interval_for_response_caching(): int {
+		return 10 * MINUTE_IN_SECONDS;
+	}
+
 	/**
 	 * Get the names of response headers to include in caching.
 	 *
@@ -854,6 +927,292 @@ trait RestApiCache {
 		return md5( false === $json ? '' : $json );
 	}

+	/**
+	 * Get the filtered list of allowed directories for file-based response caching.
+	 *
+	 * This method retrieves the allowed directories from the protected method
+	 * and applies the woocommerce_rest_api_cache_allowed_file_directories filter.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @return array Array of absolute directory paths.
+	 */
+	private function get_filtered_allowed_directories_for_response_caching(): array {
+		$allowed_directories = $this->get_allowed_directories_for_file_based_response_caching();
+
+		/**
+		 * Filter the directories allowed for file-based REST API response caching.
+		 *
+		 * This filter allows extensions to add additional directories that can contain
+		 * files tracked for cache invalidation. The first directory in the array is
+		 * used as the base path for resolving relative file paths.
+		 *
+		 * @since 10.6.0
+		 *
+		 * @param array  $allowed_directories Array of absolute directory paths.
+		 * @param object $controller          The controller instance.
+		 *
+		 * @return array Filtered array of directory paths.
+		 */
+		return apply_filters(
+			'woocommerce_rest_api_cache_allowed_file_directories',
+			$allowed_directories,
+			$this
+		);
+	}
+
+	/**
+	 * Generate a hash for the given file paths based on their modification times.
+	 *
+	 * This method resolves relative paths (relative to the first allowed directory),
+	 * gets file modification times, and generates a hash for cache invalidation.
+	 * Files that cannot be accessed (permissions, non-existent) are logged as warnings
+	 * and excluded from tracking.
+	 *
+	 * To avoid filesystem calls on every request, file check results are cached
+	 * for the interval returned by get_file_check_interval_for_response_caching().
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param array $file_paths Array of file paths (absolute or relative to the first allowed directory).
+	 *
+	 * @return string Hash string, or empty string if no files could be tracked.
+	 */
+	private function generate_files_hash( array $file_paths ): string {
+		if ( empty( $file_paths ) ) {
+			return '';
+		}
+
+		$allowed_directories = $this->get_filtered_allowed_directories_for_response_caching();
+		if ( empty( $allowed_directories ) ) {
+			$this->log_file_tracking_warning( '', 'No allowed directories configured for file tracking' );
+			return '';
+		}
+
+		$files_data     = null;
+		$check_interval = $this->get_file_check_interval_for_response_caching();
+
+		// Try to get cached file check results to avoid filesystem calls on every request.
+		if ( $check_interval > 0 ) {
+			$file_check_cache_key = $this->get_file_check_cache_key( $file_paths, $allowed_directories );
+			$files_data           = wp_cache_get( $file_check_cache_key, self::$cache_group );
+			if ( false === $files_data ) {
+				$files_data = null;
+			}
+		}
+
+		// Cache miss or caching disabled - check all files.
+		if ( is_null( $files_data ) ) {
+			$files_data = $this->check_files( $file_paths, $allowed_directories );
+
+			// Cache the results if caching is enabled.
+			if ( $check_interval > 0 && ! empty( $files_data ) ) {
+				wp_cache_set( $file_check_cache_key, $files_data, self::$cache_group, $check_interval );
+			}
+		}
+
+		/**
+		 * Filter the file data used for REST API response cache invalidation.
+		 *
+		 * This filter allows modification of the file tracking data before it is stored
+		 * in the cache and used for invalidation checks.
+		 *
+		 * @since 10.6.0
+		 *
+		 * @param array  $files_data Array of file data, each with 'path' and 'time' keys.
+		 * @param array  $file_paths Original file paths passed to the method.
+		 * @param object $controller Controller instance.
+		 */
+		$files_data = apply_filters(
+			'woocommerce_rest_api_cache_files_hash_data',
+			$files_data,
+			$file_paths,
+			$this
+		);
+
+		if ( empty( $files_data ) ) {
+			return '';
+		}
+
+		$json = wp_json_encode( $files_data );
+		return md5( false === $json ? '' : $json );
+	}
+
+	/**
+	 * Generate a cache key for file check results.
+	 *
+	 * @param array $file_paths          Array of file paths to track.
+	 * @param array $allowed_directories Array of allowed directory paths.
+	 *
+	 * @return string Cache key.
+	 */
+	private function get_file_check_cache_key( array $file_paths, array $allowed_directories ): string {
+		sort( $file_paths );
+		sort( $allowed_directories );
+		$key_data = array(
+			'files' => $file_paths,
+			'dirs'  => $allowed_directories,
+		);
+		$json     = wp_json_encode( $key_data );
+		return 'wc_rest_file_check_' . md5( false === $json ? '' : $json );
+	}
+
+	/**
+	 * Check files and return their tracking data.
+	 *
+	 * @param array $file_paths          Array of file paths to check.
+	 * @param array $allowed_directories Array of allowed directory paths.
+	 *
+	 * @return array Array of file data, each with 'path' and 'time' keys.
+	 */
+	private function check_files( array $file_paths, array $allowed_directories ): array {
+		$files_data = array();
+
+		foreach ( $file_paths as $file_path ) {
+			$resolved_path = $this->resolve_file_path( $file_path, $allowed_directories );
+
+			if ( is_null( $resolved_path ) ) {
+				$this->log_file_tracking_warning( $file_path, 'Path could not be resolved or is outside allowed directories' );
+				continue;
+			}
+
+			$file_entry = $this->get_file_tracking_entry( $resolved_path );
+			if ( is_null( $file_entry ) ) {
+				$this->log_file_tracking_warning( $resolved_path, 'File does not exist or cannot be accessed' );
+				continue;
+			}
+
+			$files_data[] = $file_entry;
+		}
+
+		return $files_data;
+	}
+
+	/**
+	 * Resolve a file path to an absolute path.
+	 *
+	 * Relative paths are resolved relative to the first directory in the allowed directories list.
+	 * All paths are converted to physical paths (symlinks resolved) for consistent comparison.
+	 * Paths that resolve outside the allowed directories are rejected for security.
+	 *
+	 * @param string $file_path           The file path to resolve (absolute or relative).
+	 * @param array  $allowed_directories Array of allowed directory paths.
+	 *
+	 * @return string|null The resolved absolute path, or null if the path is invalid or outside allowed directories.
+	 */
+	private function resolve_file_path( string $file_path, array $allowed_directories ): ?string {
+		if ( empty( $allowed_directories ) ) {
+			return null;
+		}
+
+		if ( ! path_is_absolute( $file_path ) ) {
+			$base_path = trailingslashit( $allowed_directories[0] );
+			$file_path = $base_path . ltrim( $file_path, '/' );
+		}
+
+		$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
+
+		$physical_path = $legacy_proxy->call_function( 'realpath', $file_path );
+		if ( false === $physical_path ) {
+			return null;
+		}
+
+		$normalized_path = wp_normalize_path( $physical_path );
+
+		foreach ( $allowed_directories as $dir ) {
+			$real_dir = $legacy_proxy->call_function( 'realpath', $dir );
+			if ( false === $real_dir ) {
+				continue;
+			}
+
+			$normalized_dir = trailingslashit( wp_normalize_path( $real_dir ) );
+			if ( 0 === strpos( $normalized_path, $normalized_dir ) ) {
+				return $normalized_path;
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Log a warning about a file that couldn't be tracked.
+	 *
+	 * Each unique file path + reason combination is logged only once per the
+	 * suppression TTL period to avoid flooding the log with repeated warnings.
+	 * With a persistent object cache (Redis, Memcached), this works across requests.
+	 * Without one, it prevents duplicates within the same request.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param string $file_path The file path that couldn't be tracked.
+	 * @param string $reason    The reason the file couldn't be tracked.
+	 */
+	private function log_file_tracking_warning( string $file_path, string $reason ): void {
+		/**
+		 * Filter the TTL for suppressing duplicate file tracking warnings.
+		 *
+		 * By default, each unique warning (file path + reason) is logged only once per hour
+		 * to avoid flooding the log. Use this filter to customize the suppression period.
+		 * Return 0 to disable suppression and log all warnings.
+		 *
+		 * @since 10.6.0
+		 *
+		 * @param int    $ttl       The suppression TTL in seconds. Default is HOUR_IN_SECONDS.
+		 * @param string $file_path The file path that couldn't be tracked.
+		 * @param string $reason    The reason the file couldn't be tracked.
+		 */
+		$suppression_ttl = apply_filters(
+			'woocommerce_rest_api_cache_file_warning_suppression_ttl',
+			self::$file_warning_suppression_ttl,
+			$file_path,
+			$reason
+		);
+
+		if ( $suppression_ttl > 0 ) {
+			$warning_key = 'wc_rest_file_warning_' . md5( $file_path . '|' . $reason );
+
+			if ( false !== wp_cache_get( $warning_key, self::$warning_cache_group ) ) {
+				return;
+			}
+
+			wp_cache_set( $warning_key, true, self::$warning_cache_group, $suppression_ttl );
+		}
+
+		$logger = wc_get_container()->get( LegacyProxy::class )->call_function( 'wc_get_logger' );
+		$logger->warning(
+			sprintf(
+				'REST API cache: Could not track file "%s" for cache invalidation. Reason: %s',
+				$file_path,
+				$reason
+			),
+			array( 'source' => 'rest-api-cache' )
+		);
+	}
+
+	/**
+	 * Get file tracking entry for a resolved path.
+	 *
+	 * @since 10.6.0
+	 *
+	 * @param string $resolved_path The resolved absolute file path.
+	 *
+	 * @return array{path: string, time: int}|null File entry with path and time, or null if file can't be accessed.
+	 */
+	private function get_file_tracking_entry( string $resolved_path ): ?array {
+		$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
+
+		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- We handle the error gracefully.
+		$mtime = @$legacy_proxy->call_function( 'filemtime', $resolved_path );
+		if ( false === $mtime ) {
+			return null;
+		}
+
+		return array(
+			'path' => $resolved_path,
+			'time' => $mtime,
+		);
+	}
+
 	/**
 	 * Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
 	 *
@@ -894,6 +1253,18 @@ trait RestApiCache {
 			}
 		}

+		// Validate files hash if files are being tracked.
+		$relevant_files = $cached_config['relevant_files'];
+		if ( ! empty( $relevant_files ) ) {
+			$cached_files_hash  = $cached['files_hash'] ?? '';
+			$current_files_hash = $this->generate_files_hash( $relevant_files );
+
+			if ( $current_files_hash !== $cached_files_hash ) {
+				wp_cache_delete( $cache_key, self::$cache_group );
+				return null;
+			}
+		}
+
 		if ( ! is_null( $this->version_string_generator ) ) {
 			foreach ( $cached['entity_versions'] as $entity_id => $cached_version ) {
 				$version_id      = "{$entity_type}_{$entity_id}";
@@ -966,10 +1337,11 @@ trait RestApiCache {
 	 * @param array  $entity_ids     Array of entity IDs in the response.
 	 * @param int    $cache_ttl      Cache TTL in seconds.
 	 * @param array  $relevant_hooks Hook names to track for invalidation.
+	 * @param array  $relevant_files File paths to track for invalidation.
 	 * @param array  $headers        Response headers to cache.
 	 * @param string $etag           ETag for the response.
 	 */
-	private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids, int $cache_ttl, array $relevant_hooks, array $headers = array(), string $etag = '' ): void {
+	private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids, int $cache_ttl, array $relevant_hooks, array $relevant_files, array $headers = array(), string $etag = '' ): void {
 		$entity_versions = array();
 		if ( ! is_null( $this->version_string_generator ) ) {
 			foreach ( $entity_ids as $entity_id ) {
@@ -996,6 +1368,13 @@ trait RestApiCache {
 			$cache_data['hooks_hash'] = $this->generate_hooks_hash( $relevant_hooks );
 		}

+		if ( ! empty( $relevant_files ) ) {
+			$files_hash = $this->generate_files_hash( $relevant_files );
+			if ( ! empty( $files_hash ) ) {
+				$cache_data['files_hash'] = $files_hash;
+			}
+		}
+
 		if ( ! empty( $headers ) ) {
 			$cache_data['headers'] = $headers;
 		}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
index 9bc2fb799b..9dc3209673 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
@@ -68,6 +68,12 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 		$this->version_generator = wc_get_container()->get( VersionStringGenerator::class );
 		$this->sut               = $this->create_test_controller();

+		// Set default allowed directories to include WC_ABSPATH and temp dir for file tracking tests.
+		$this->sut->allowed_directories = array( WC_ABSPATH, sys_get_temp_dir() );
+
+		// Disable file check caching by default so tests can modify files and expect immediate invalidation.
+		$this->sut->file_check_interval = 0;
+
 		$this->reset_rest_server();
 	}

@@ -960,6 +966,10 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 					'id'   => 90,
 					'name' => 'Product with Controller Hooks',
 				),
+				'with_controller_files'  => array(
+					'id'   => 91,
+					'name' => 'Product with Controller Files',
+				),
 				'standard'               => array(
 					'id'   => 10,
 					'name' => 'Standard Product',
@@ -982,10 +992,13 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 			public $endpoint_vary_by_user  = array();
 			public $endpoint_ids           = array();
 			public $controller_hooks       = array();
+			public $controller_files       = array();
 			public $response_headers       = array();
 			public $custom_exclude_headers = array();
 			public $custom_include_headers = false;
 			public $endpoint_cache_config  = array();
+			public $allowed_directories    = null;
+			public $file_check_interval    = null;

 			public function __construct() {
 				$this->namespace = 'wc/v3';
@@ -1005,6 +1018,7 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 				$this->register_multi_method_route();
 				$this->register_cached_route( 'with_endpoint_hooks', array( 'relevant_hooks' => array( 'test_endpoint_hook_for_caching' ) ) );
 				$this->register_cached_route( 'with_controller_hooks' );
+				$this->register_cached_route( 'with_controller_files' );
 				$this->register_cached_route( 'standard' );
 				$this->register_custom_config_route( 'custom_endpoint_config' );
 				$this->register_custom_config_route( 'test_false_override' );
@@ -1092,6 +1106,24 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 				return $this->controller_hooks;
 			}

+			protected function get_files_relevant_to_response_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array {
+				return $this->controller_files;
+			}
+
+			protected function get_allowed_directories_for_file_based_response_caching(): array {
+				if ( null !== $this->allowed_directories ) {
+					return $this->allowed_directories;
+				}
+				return parent::get_allowed_directories_for_file_based_response_caching();
+			}
+
+			protected function get_file_check_interval_for_response_caching(): int {
+				if ( null !== $this->file_check_interval ) {
+					return $this->file_check_interval;
+				}
+				return parent::get_file_check_interval_for_response_caching();
+			}
+
 			protected function get_response_headers_to_include_in_caching( WP_REST_Request $request, ?string $endpoint_id = null ) {
 				return $this->custom_include_headers;
 			}
@@ -1210,54 +1242,30 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Cache-Control header is "private" when user is logged in and vary_by_user is true.
-	 */
-	public function test_cache_control_private_when_user_logged_in() {
-		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
-			array( 'is_user_logged_in' => fn() => true )
-		);
-
-		$response = $this->query_endpoint( 'single_entity' );
-
-		$headers = $response->get_headers();
-		$this->assertArrayHasKey( 'Cache-Control', $headers );
-		$this->assertStringContainsString( 'private', $headers['Cache-Control'] );
-		$this->assertStringContainsString( 'must-revalidate', $headers['Cache-Control'] );
-		$this->assertStringContainsString( 'max-age=', $headers['Cache-Control'] );
-	}
-
-	/**
-	 * @testdox Cache-Control header is "public" when no user is logged in even with vary_by_user true.
+	 * @testdox Cache-Control header visibility is set correctly based on login status and vary_by_user setting.
+	 *
+	 * @testWith [true, "single_entity", "private"]
+	 *           [false, "single_entity", "public"]
+	 *           [true, "no_vary_by_user", "public"]
+	 *
+	 * @param bool   $logged_in           Whether user is logged in.
+	 * @param string $endpoint            The endpoint to query.
+	 * @param string $expected_visibility Expected Cache-Control visibility (public or private).
 	 */
-	public function test_cache_control_public_when_no_user() {
+	public function test_cache_control_header_visibility( bool $logged_in, string $endpoint, string $expected_visibility ) {
 		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
-			array( 'is_user_logged_in' => fn() => false )
+			array( 'is_user_logged_in' => fn() => $logged_in )
 		);

-		$response = $this->query_endpoint( 'single_entity' );
+		$response = $this->query_endpoint( $endpoint );

 		$headers = $response->get_headers();
 		$this->assertArrayHasKey( 'Cache-Control', $headers );
-		$this->assertStringContainsString( 'public', $headers['Cache-Control'] );
+		$this->assertStringContainsString( $expected_visibility, $headers['Cache-Control'] );
 		$this->assertStringContainsString( 'must-revalidate', $headers['Cache-Control'] );
 		$this->assertStringContainsString( 'max-age=', $headers['Cache-Control'] );
 	}

-	/**
-	 * @testdox Cache-Control header is "public" when vary_by_user is false regardless of login status.
-	 */
-	public function test_cache_control_public_when_vary_by_user_false() {
-		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
-			array( 'is_user_logged_in' => fn() => true )
-		);
-
-		$response = $this->query_endpoint( 'no_vary_by_user' );
-
-		$headers = $response->get_headers();
-		$this->assertArrayHasKey( 'Cache-Control', $headers );
-		$this->assertStringContainsString( 'public', $headers['Cache-Control'] );
-	}
-
 	/**
 	 * @testdox Date and X-WC-Date headers are present and correctly formatted on cache HIT.
 	 */
@@ -1605,4 +1613,690 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
 		$has_filter = has_filter( 'rest_send_nocache_headers', array( $controller, 'handle_rest_send_nocache_headers' ) );
 		$this->assertFalse( $has_filter );
 	}
+
+	/**
+	 * @testdox Cache is invalidated when a controller-level tracked file is modified or deleted.
+	 *
+	 * @testWith ["modify"]
+	 *           ["delete"]
+	 *
+	 * @param string $action The action to perform on the file (modify or delete).
+	 */
+	public function test_cache_invalidated_when_controller_file_changes( string $action ) {
+		$test_file = $this->create_temp_test_file();
+
+		$this->sut->controller_files = array( $test_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertCount( 1, $this->get_all_cache_keys() );
+
+		$response2 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		if ( 'delete' === $action ) {
+			unlink( $test_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+		} else {
+			touch( $test_file, time() + 10 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
+		}
+		clearstatcache( true, $test_file );
+
+		$response3 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response3, 'MISS', "Cache should be invalidated when tracked file is {$action}d" );
+
+		if ( 'modify' === $action ) {
+			unlink( $test_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+		}
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox Files hash is stored in cache when tracking files.
+	 */
+	public function test_files_hash_stored_in_cache() {
+		$test_file = $this->create_temp_test_file();
+
+		$this->sut->controller_files = array( $test_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys = $this->get_all_cache_keys();
+		$this->assertCount( 1, $cache_keys );
+		$cache_key    = $cache_keys[0];
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+		$this->assertArrayHasKey( 'files_hash', $cached_entry );
+		$this->assertNotEmpty( $cached_entry['files_hash'] );
+
+		unlink( $test_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox Cache is invalidated when endpoint-level tracked files change.
+	 */
+	public function test_cache_invalidated_when_endpoint_files_change() {
+		$test_file = $this->create_temp_test_file();
+
+		$this->sut->endpoint_cache_config['custom_endpoint_config']['config'] = array(
+			'relevant_files' => array( $test_file ),
+		);
+		$this->sut->reinitialize_cache();
+		$this->reset_rest_server();
+
+		$response1 = $this->query_endpoint( 'custom_endpoint_config' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$response2 = $this->query_endpoint( 'custom_endpoint_config' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		touch( $test_file, time() + 10 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
+		clearstatcache( true, $test_file );
+
+		$response3 = $this->query_endpoint( 'custom_endpoint_config' );
+		$this->assertCacheHeader( $response3, 'MISS', 'Cache should be invalidated when endpoint-tracked file changes' );
+
+		unlink( $test_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+	}
+
+	/**
+	 * @testdox Non-existent files are not tracked and do not prevent caching.
+	 */
+	public function test_non_existent_files_not_tracked() {
+		$non_existent_file = sys_get_temp_dir() . '/wc_test_non_existent_' . uniqid() . '.txt';
+
+		$this->sut->controller_files = array( $non_existent_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys = $this->get_all_cache_keys();
+		$this->assertCount( 1, $cache_keys );
+		$cache_key    = $cache_keys[0];
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+		$this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'Files hash should not be stored when no files are tracked' );
+
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox Filter woocommerce_rest_api_cache_files_hash_data can modify file tracking data.
+	 */
+	public function test_filter_can_modify_files_hash_data() {
+		$test_file = $this->create_temp_test_file();
+
+		$this->sut->controller_files = array( $test_file );
+
+		$filter_called = false;
+		$filter        = function ( $files_data, $file_paths, $controller ) use ( &$filter_called, $test_file ) {
+			unset( $controller );
+			$filter_called = true;
+			$this->assertIsArray( $files_data );
+			$this->assertIsArray( $file_paths );
+			$this->assertContains( $test_file, $file_paths );
+			return array();
+		};
+		add_filter( 'woocommerce_rest_api_cache_files_hash_data', $filter, 10, 3 );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+		$this->assertTrue( $filter_called, 'Filter should have been called' );
+
+		$cache_keys = $this->get_all_cache_keys();
+		$cache_key  = $cache_keys[0];
+
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+		$this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'Filter cleared files data, so no hash should be stored' );
+
+		remove_filter( 'woocommerce_rest_api_cache_files_hash_data', $filter, 10 );
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+		unlink( $test_file );
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox Relative file paths are resolved relative to the first allowed directory.
+	 */
+	public function test_relative_file_paths_resolved() {
+		$this->sut->controller_files = array( 'woocommerce.php' );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys = $this->get_all_cache_keys();
+		$cache_key  = $cache_keys[0];
+
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+		$this->assertArrayHasKey( 'files_hash', $cached_entry, 'Relative path should be resolved and tracked' );
+		$this->assertNotEmpty( $cached_entry['files_hash'] );
+
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox Filter woocommerce_rest_api_cache_allowed_file_directories can add directories.
+	 */
+	public function test_filter_can_add_allowed_directories() {
+		$this->sut->allowed_directories = array( WC_ABSPATH );
+
+		$test_file = $this->create_temp_test_file();
+
+		$this->sut->controller_files = array( $test_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys   = $this->get_all_cache_keys();
+		$cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+		$this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'File should not be tracked without temp dir in allowed directories' );
+
+		$filter = function ( $directories, $controller ) {
+			unset( $controller );
+			$directories[] = sys_get_temp_dir();
+			return $directories;
+		};
+		add_filter( 'woocommerce_rest_api_cache_allowed_file_directories', $filter, 10, 2 );
+
+		wp_cache_delete( $cache_keys[0], self::CACHE_GROUP );
+
+		$response2 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response2, 'MISS' );
+
+		$cache_keys   = $this->get_all_cache_keys();
+		$cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+		$this->assertArrayHasKey( 'files_hash', $cached_entry, 'File should be tracked after adding temp dir via filter' );
+
+		remove_filter( 'woocommerce_rest_api_cache_allowed_file_directories', $filter, 10 );
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+		unlink( $test_file );
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox Controller can override get_allowed_directories_for_file_based_response_caching.
+	 */
+	public function test_controller_can_override_allowed_directories() {
+		$test_file = $this->create_temp_test_file();
+
+		$this->sut->allowed_directories = array( WC_ABSPATH );
+		$this->sut->controller_files    = array( $test_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys   = $this->get_all_cache_keys();
+		$cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+		$this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'File outside allowed directories should not be tracked' );
+
+		$this->sut->allowed_directories = array( WC_ABSPATH, sys_get_temp_dir() );
+
+		wp_cache_delete( $cache_keys[0], self::CACHE_GROUP );
+
+		$response2 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response2, 'MISS' );
+
+		$cache_keys   = $this->get_all_cache_keys();
+		$cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+		$this->assertArrayHasKey( 'files_hash', $cached_entry, 'File should be tracked when its directory is allowed' );
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+		unlink( $test_file );
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox File check results are cached for the configured interval.
+	 */
+	public function test_file_check_results_are_cached() {
+		$test_file = $this->create_temp_test_file();
+
+		$this->sut->controller_files    = array( $test_file );
+		$this->sut->file_check_interval = 600; // 10 minutes.
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$file_check_keys = array_filter(
+			$this->get_all_cache_keys(),
+			fn( $key ) => strpos( $key, 'wc_rest_file_check_' ) === 0
+		);
+		$this->assertCount( 1, $file_check_keys, 'File check cache entry should be created' );
+
+		// Modify the file - but since file check is cached, it won't be detected yet.
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- Test file modification.
+		touch( $test_file, time() + 10 );
+		clearstatcache( true, $test_file );
+
+		$response2 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response2, 'HIT', 'File change should not be detected while file check is cached' );
+
+		// Delete the file check cache to simulate interval expiration.
+		$file_check_key = array_values( $file_check_keys )[0];
+		wp_cache_delete( $file_check_key, self::CACHE_GROUP );
+
+		$response3 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response3, 'MISS', 'File change should be detected after file check cache expires' );
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+		unlink( $test_file );
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox File check caching can be disabled by returning 0 from get_file_check_interval_for_response_caching.
+	 */
+	public function test_file_check_caching_disabled_with_zero_interval() {
+		$test_file = $this->create_temp_test_file();
+
+		$this->sut->controller_files    = array( $test_file );
+		$this->sut->file_check_interval = 0; // Disabled.
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$file_check_keys = array_filter(
+			$this->get_all_cache_keys(),
+			fn( $key ) => strpos( $key, 'wc_rest_file_check_' ) === 0
+		);
+		$this->assertCount( 0, $file_check_keys, 'No file check cache entry should be created when interval is 0' );
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- Test file modification.
+		touch( $test_file, time() + 10 );
+		clearstatcache( true, $test_file );
+
+		$response2 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response2, 'MISS', 'File change should be detected immediately when caching is disabled' );
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+		unlink( $test_file );
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox Multiple files can be tracked simultaneously.
+	 */
+	public function test_multiple_files_tracked() {
+		$test_file1 = $this->create_temp_test_file();
+		$test_file2 = $this->create_temp_test_file();
+
+		$this->sut->controller_files = array( $test_file1, $test_file2 );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys = $this->get_all_cache_keys();
+		$cache_key  = $cache_keys[0];
+
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+		$this->assertArrayHasKey( 'files_hash', $cached_entry );
+		$this->assertNotEmpty( $cached_entry['files_hash'] );
+
+		$response2 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- Test file modification.
+		touch( $test_file2, time() + 10 );
+		clearstatcache( true, $test_file2 );
+
+		$response3 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response3, 'MISS', 'Cache should be invalidated when any tracked file changes' );
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+		unlink( $test_file1 );
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+		unlink( $test_file2 );
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox File path resolution failure (realpath returns false) is handled gracefully.
+	 */
+	public function test_file_path_resolution_failure_handled() {
+		$fake_file = '/some/nonexistent/path/file.txt';
+
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array(
+				'realpath' => function ( $path ) use ( $fake_file ) {
+					// Return false for the fake file, real path for others.
+					return $path === $fake_file ? false : \realpath( $path );
+				},
+			)
+		);
+
+		$this->sut->controller_files = array( $fake_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys   = $this->get_all_cache_keys();
+		$cache_key    = $cache_keys[0];
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+		// No files_hash should be stored since file resolution failed.
+		$this->assertArrayNotHasKey( 'files_hash', $cached_entry );
+
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox File access error (filemtime returns false) is handled gracefully.
+	 */
+	public function test_file_access_error_handled() {
+		$fake_file      = WC_ABSPATH . 'unreadable_file.txt';
+		$fake_file_real = WC_ABSPATH . 'unreadable_file_real.txt';
+
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array(
+				'realpath'  => function ( $path ) use ( $fake_file, $fake_file_real ) {
+					if ( $path === $fake_file ) {
+						return $fake_file_real;
+					}
+					return \realpath( $path );
+				},
+				'filemtime' => function ( $path ) use ( $fake_file_real ) {
+					// Return false for the fake file (simulating access error).
+					return $path === $fake_file_real ? false : \filemtime( $path );
+				},
+			)
+		);
+
+		$this->sut->controller_files = array( $fake_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys   = $this->get_all_cache_keys();
+		$cache_key    = $cache_keys[0];
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+		// No files_hash should be stored since file access failed.
+		$this->assertArrayNotHasKey( 'files_hash', $cached_entry );
+
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox Cache is invalidated when file becomes inaccessible after being cached.
+	 */
+	public function test_cache_invalidated_when_file_becomes_inaccessible() {
+		$fake_file       = WC_ABSPATH . 'accessible_file.txt';
+		$fake_file_real  = WC_ABSPATH . 'accessible_file_real.txt';
+		$file_accessible = true;
+
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array(
+				'realpath'  => function ( $path ) use ( $fake_file, $fake_file_real, &$file_accessible ) {
+					if ( $path === $fake_file ) {
+						return $file_accessible ? $fake_file_real : false;
+					}
+					return \realpath( $path );
+				},
+				'filemtime' => function ( $path ) use ( $fake_file_real, &$file_accessible ) {
+					if ( $path === $fake_file_real ) {
+						return $file_accessible ? 1234567890 : false;
+					}
+					return \filemtime( $path );
+				},
+			)
+		);
+
+		$this->sut->controller_files = array( $fake_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys   = $this->get_all_cache_keys();
+		$cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+		$this->assertArrayHasKey( 'files_hash', $cached_entry );
+
+		$response2 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		$file_accessible = false;
+
+		$response3 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response3, 'MISS', 'Cache should be invalidated when file becomes inaccessible' );
+
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox File outside allowed directories is rejected.
+	 */
+	public function test_file_outside_allowed_directories_rejected() {
+		$outside_file      = '/etc/passwd';
+		$outside_file_real = '/etc/passwd';
+
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array(
+				'realpath' => function ( $path ) use ( $outside_file, $outside_file_real ) {
+					if ( $path === $outside_file ) {
+						return $outside_file_real;
+					}
+					return \realpath( $path );
+				},
+			)
+		);
+
+		$this->sut->controller_files = array( $outside_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys   = $this->get_all_cache_keys();
+		$cache_key    = $cache_keys[0];
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+		// No files_hash should be stored since file is outside allowed directories.
+		$this->assertArrayNotHasKey( 'files_hash', $cached_entry );
+
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox Files in directories with similar prefix names are correctly rejected (prefix collision prevention).
+	 */
+	public function test_directory_prefix_collision_prevented() {
+		// Simulate a directory structure where /var/www/htmlevil exists alongside /var/www/html.
+		// A file in /var/www/htmlevil should NOT be allowed when only /var/www/html is permitted.
+		$allowed_dir = '/var/www/html';
+		$evil_file   = '/var/www/htmlevil/malicious.php';
+
+		$this->sut->allowed_directories = array( $allowed_dir );
+
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array(
+				'realpath' => function ( $path ) use ( $allowed_dir, $evil_file ) {
+					if ( $path === $allowed_dir || $path === $evil_file ) {
+						return $path;
+					}
+					return \realpath( $path );
+				},
+			)
+		);
+
+		$this->sut->controller_files = array( $evil_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$cache_keys   = $this->get_all_cache_keys();
+		$cache_key    = $cache_keys[0];
+		$cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+		$this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'File in /var/www/htmlevil should not be allowed when only /var/www/html is permitted' );
+
+		$this->sut->controller_files    = array();
+		$this->sut->allowed_directories = null;
+	}
+
+	/**
+	 * @testdox Cache is invalidated when file modification time changes (using mocked filemtime).
+	 */
+	public function test_cache_invalidated_when_mocked_file_mtime_changes() {
+		$fake_file      = WC_ABSPATH . 'tracked_file.txt';
+		$fake_file_real = WC_ABSPATH . 'tracked_file_real.txt';
+		$file_mtime     = 1234567890;
+
+		wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+			array(
+				'realpath'  => function ( $path ) use ( $fake_file, $fake_file_real ) {
+					if ( $path === $fake_file ) {
+						return $fake_file_real;
+					}
+					return \realpath( $path );
+				},
+				'filemtime' => function ( $path ) use ( $fake_file_real, &$file_mtime ) {
+					if ( $path === $fake_file_real ) {
+						return $file_mtime;
+					}
+					return \filemtime( $path );
+				},
+			)
+		);
+
+		$this->sut->controller_files = array( $fake_file );
+
+		$response1 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response1, 'MISS' );
+
+		$response2 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response2, 'HIT' );
+
+		$file_mtime = 1234567999;
+
+		$response3 = $this->query_endpoint( 'with_controller_files' );
+		$this->assertCacheHeader( $response3, 'MISS', 'Cache should be invalidated when file mtime changes' );
+
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox File tracking warnings are suppressed by default to avoid log flooding.
+	 */
+	public function test_file_tracking_warnings_are_suppressed_by_default() {
+		wp_cache_flush_group( 'woocommerce_rest_api_cache_warnings' );
+
+		$warning_count = 0;
+		$logger_mock   = $this->createMock( \WC_Logger::class );
+		$logger_mock->expects( $this->any() )
+			->method( 'warning' )
+			->willReturnCallback(
+				function () use ( &$warning_count ) {
+					++$warning_count;
+				}
+			);
+
+		$this->register_legacy_proxy_function_mocks(
+			array( 'wc_get_logger' => fn() => $logger_mock )
+		);
+
+		// Use a file path in temp dir (which is in allowed_directories).
+		$non_existent_file           = sys_get_temp_dir() . '/wc_test_warning_' . uniqid() . '.txt';
+		$this->sut->controller_files = array( $non_existent_file );
+
+		// First request should log the warning.
+		$this->query_endpoint( 'with_controller_files' );
+		$this->assertSame( 1, $warning_count, 'First request should log the warning' );
+
+		// Subsequent request should NOT log the warning again (suppressed).
+		$this->query_endpoint( 'with_controller_files' );
+		$this->assertSame( 1, $warning_count, 'Second request should not log again (suppressed)' );
+
+		$this->query_endpoint( 'with_controller_files' );
+		$this->assertSame( 1, $warning_count, 'Third request should not log again (still suppressed)' );
+
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox File tracking warnings are not suppressed when filter returns zero.
+	 */
+	public function test_file_tracking_warnings_not_suppressed_when_filter_returns_zero() {
+		wp_cache_flush_group( 'woocommerce_rest_api_cache_warnings' );
+
+		$warning_count = 0;
+		$logger_mock   = $this->createMock( \WC_Logger::class );
+		$logger_mock->expects( $this->any() )
+			->method( 'warning' )
+			->willReturnCallback(
+				function () use ( &$warning_count ) {
+					++$warning_count;
+				}
+			);
+
+		$this->register_legacy_proxy_function_mocks(
+			array( 'wc_get_logger' => fn() => $logger_mock )
+		);
+
+		// Disable warning suppression.
+		add_filter( 'woocommerce_rest_api_cache_file_warning_suppression_ttl', '__return_zero' );
+
+		// Use a file path in temp dir (which is in allowed_directories).
+		$non_existent_file           = sys_get_temp_dir() . '/wc_test_warning_' . uniqid() . '.txt';
+		$this->sut->controller_files = array( $non_existent_file );
+
+		// All three requests should log the warning.
+		$this->query_endpoint( 'with_controller_files' );
+		$this->assertSame( 1, $warning_count, 'First request should log the warning' );
+
+		$this->query_endpoint( 'with_controller_files' );
+		$this->assertSame( 2, $warning_count, 'Second request should log again (suppression disabled)' );
+
+		$this->query_endpoint( 'with_controller_files' );
+		$this->assertSame( 3, $warning_count, 'Third request should log again (suppression disabled)' );
+
+		remove_filter( 'woocommerce_rest_api_cache_file_warning_suppression_ttl', '__return_zero' );
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * @testdox File tracking warning suppression filter receives correct parameters.
+	 */
+	public function test_file_tracking_warning_suppression_filter_receives_correct_parameters() {
+		wp_cache_flush_group( 'woocommerce_rest_api_cache_warnings' );
+
+		$filter_called   = false;
+		$received_params = array();
+
+		$filter = function ( $ttl, $file_path, $reason ) use ( &$filter_called, &$received_params ) {
+			$filter_called   = true;
+			$received_params = array(
+				'ttl'       => $ttl,
+				'file_path' => $file_path,
+				'reason'    => $reason,
+			);
+			return $ttl;
+		};
+
+		add_filter( 'woocommerce_rest_api_cache_file_warning_suppression_ttl', $filter, 10, 3 );
+
+		// Use a file path in temp dir (which is in allowed_directories).
+		$non_existent_file           = sys_get_temp_dir() . '/wc_test_warning_' . uniqid() . '.txt';
+		$this->sut->controller_files = array( $non_existent_file );
+
+		$this->query_endpoint( 'with_controller_files' );
+
+		$this->assertTrue( $filter_called, 'Filter should be called' );
+		$this->assertSame( HOUR_IN_SECONDS, $received_params['ttl'], 'Default TTL should be HOUR_IN_SECONDS' );
+		$this->assertSame( $non_existent_file, $received_params['file_path'], 'File path should be passed to filter' );
+		$this->assertNotEmpty( $received_params['reason'], 'Reason should be passed to filter' );
+
+		remove_filter( 'woocommerce_rest_api_cache_file_warning_suppression_ttl', $filter, 10 );
+		$this->sut->controller_files = array();
+	}
+
+	/**
+	 * Create a temporary test file for file tracking tests.
+	 *
+	 * @return string Path to the created file.
+	 */
+	private function create_temp_test_file(): string {
+		$temp_file = sys_get_temp_dir() . '/wc_test_file_' . uniqid() . '.txt';
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test file creation.
+		file_put_contents( $temp_file, 'test content' );
+		return $temp_file;
+	}
 }