@@ -849,6 +849,75 @@ public function validate_containment( string $target, string $container ): array
849849 return PathSecurity::validateContainment ($ target , $ container );
850850 }
851851
852+ /**
853+ * Recursively remove an existing directory after validating containment.
854+ *
855+ * The target must be a real directory inside, but not equal to, the container.
856+ * Symlinks are unlinked as entries and never traversed.
857+ *
858+ * @param string $absolute Absolute directory path to remove.
859+ * @param string $container Parent directory that must contain the target.
860+ * @param string|null $relative_base Base path used to report deleted relative paths.
861+ * @return array<int,string>|\WP_Error
862+ */
863+ protected function remove_contained_directory_recursive ( string $ absolute , string $ container , ?string $ relative_base = null ): array |\WP_Error {
864+ $ validation = $ this ->validate_containment ($ absolute , $ container );
865+ if ( ! $ validation ['valid ' ] ) {
866+ return new \WP_Error ('path_traversal ' , (string ) ( $ validation ['message ' ] ?? 'Path traversal detected. Access denied. ' ), array ( 'status ' => 403 ));
867+ }
868+
869+ $ container_real = realpath ($ container );
870+ $ target_real = (string ) ( $ validation ['real_path ' ] ?? '' );
871+ if ( false === $ container_real || '' === $ target_real || $ target_real === $ container_real ) {
872+ return new \WP_Error ('unsafe_delete_root ' , sprintf ('Refusing to recursively delete container root: %s ' , $ absolute ), array ( 'status ' => 403 ));
873+ }
874+
875+ if ( ! is_dir ($ target_real ) || is_link ($ absolute ) ) {
876+ return new \WP_Error ('not_a_directory ' , sprintf ('Recursive delete target is not a directory: %s ' , $ absolute ), array ( 'status ' => 400 ));
877+ }
878+
879+ $ relative_base_real = realpath ($ relative_base ?? $ container );
880+ if ( false === $ relative_base_real ) {
881+ $ relative_base_real = $ container_real ;
882+ }
883+
884+ $ deleted = array ();
885+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Failure is converted into a WP_Error below.
886+ $ entries = @scandir ($ target_real );
887+ if ( false === $ entries ) {
888+ return new \WP_Error ('scandir_failed ' , sprintf ('Failed to read directory: %s ' , $ target_real ), array ( 'status ' => 500 ));
889+ }
890+
891+ foreach ( $ entries as $ entry ) {
892+ if ( '. ' === $ entry || '.. ' === $ entry ) {
893+ continue ;
894+ }
895+
896+ $ child = $ target_real . '/ ' . $ entry ;
897+ if ( is_dir ($ child ) && ! is_link ($ child ) ) {
898+ $ nested = $ this ->remove_contained_directory_recursive ($ child , $ container_real , $ relative_base_real );
899+ if ( is_wp_error ($ nested ) ) {
900+ return $ nested ;
901+ }
902+ $ deleted = array_merge ($ deleted , $ nested );
903+ } else {
904+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
905+ if ( ! unlink ($ child ) ) {
906+ return new \WP_Error ('delete_failed ' , sprintf ('Failed to delete file: %s ' , $ child ), array ( 'status ' => 500 ));
907+ }
908+ $ deleted [] = ltrim (substr ($ child , strlen ($ relative_base_real )), '/ ' );
909+ }
910+ }
911+
912+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
913+ if ( ! rmdir ($ target_real ) ) {
914+ return new \WP_Error ('delete_failed ' , sprintf ('Failed to remove directory: %s ' , $ target_real ), array ( 'status ' => 500 ));
915+ }
916+
917+ $ deleted [] = ltrim (substr ($ target_real , strlen ($ relative_base_real )), '/ ' );
918+ return $ deleted ;
919+ }
920+
852921 /**
853922 * Derive a repo name from a git URL.
854923 *
0 commit comments