diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php
index 310f1ec83be3d..9d21b3baf7525 100644
--- a/apps/dav/composer/composer/autoload_classmap.php
+++ b/apps/dav/composer/composer/autoload_classmap.php
@@ -29,6 +29,7 @@
'OCA\\DAV\\BackgroundJob\\UpdateCalendarResourcesRoomsBackgroundJob' => $baseDir . '/../lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php',
'OCA\\DAV\\BackgroundJob\\UploadCleanup' => $baseDir . '/../lib/BackgroundJob/UploadCleanup.php',
'OCA\\DAV\\BackgroundJob\\UserStatusAutomation' => $baseDir . '/../lib/BackgroundJob/UserStatusAutomation.php',
+ 'OCA\\DAV\\BulkDelete\\BulkDeletePlugin' => $baseDir . '/../lib/BulkDelete/BulkDeletePlugin.php',
'OCA\\DAV\\BulkUpload\\BulkUploadPlugin' => $baseDir . '/../lib/BulkUpload/BulkUploadPlugin.php',
'OCA\\DAV\\BulkUpload\\MultipartRequestParser' => $baseDir . '/../lib/BulkUpload/MultipartRequestParser.php',
'OCA\\DAV\\CalDAV\\Activity\\Backend' => $baseDir . '/../lib/CalDAV/Activity/Backend.php',
diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php
index 98a49d46284dd..70686320197bc 100644
--- a/apps/dav/composer/composer/autoload_static.php
+++ b/apps/dav/composer/composer/autoload_static.php
@@ -44,6 +44,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\BackgroundJob\\UpdateCalendarResourcesRoomsBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php',
'OCA\\DAV\\BackgroundJob\\UploadCleanup' => __DIR__ . '/..' . '/../lib/BackgroundJob/UploadCleanup.php',
'OCA\\DAV\\BackgroundJob\\UserStatusAutomation' => __DIR__ . '/..' . '/../lib/BackgroundJob/UserStatusAutomation.php',
+ 'OCA\\DAV\\BulkDelete\\BulkDeletePlugin' => __DIR__ . '/..' . '/../lib/BulkDelete/BulkDeletePlugin.php',
'OCA\\DAV\\BulkUpload\\BulkUploadPlugin' => __DIR__ . '/..' . '/../lib/BulkUpload/BulkUploadPlugin.php',
'OCA\\DAV\\BulkUpload\\MultipartRequestParser' => __DIR__ . '/..' . '/../lib/BulkUpload/MultipartRequestParser.php',
'OCA\\DAV\\CalDAV\\Activity\\Backend' => __DIR__ . '/..' . '/../lib/CalDAV/Activity/Backend.php',
diff --git a/apps/dav/lib/BulkDelete/BulkDeletePlugin.php b/apps/dav/lib/BulkDelete/BulkDeletePlugin.php
new file mode 100644
index 0000000000000..0b11c70571284
--- /dev/null
+++ b/apps/dav/lib/BulkDelete/BulkDeletePlugin.php
@@ -0,0 +1,297 @@
+
+ *
+ * file1
+ * folder/file2
+ *
+ *
+ *
+ * Targets are relative to the collection addressed by the BDELETE request.
+ */
+class BulkDeletePlugin extends ServerPlugin {
+ public const MAX_FILES = 100;
+ public const MAX_BODY_BYTES = 1048576;
+ public const MAX_HREF_BYTES = 4096;
+
+ private Server $server;
+
+ public function __construct(
+ private string $userId,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ #[\Override]
+ public function initialize(Server $server): void {
+ $this->server = $server;
+ $server->on('method:BDELETE', [$this, 'httpBulkDelete'], 10);
+ }
+
+ #[\Override]
+ public function getPluginName(): string {
+ return 'bulk-delete';
+ }
+
+ /**
+ * @param string $path
+ * @return string[]
+ */
+ #[\Override]
+ public function getHTTPMethods($path) {
+ return $this->isUserFilesPath($path) ? ['BDELETE'] : [];
+ }
+
+ /**
+ * Delete each target through the standard DAV unbind lifecycle.
+ */
+ public function httpBulkDelete(RequestInterface $request, ResponseInterface $response): bool {
+ $containerPath = trim($request->getPath(), '/');
+ if (!$this->isUserFilesPath($containerPath)) {
+ return true;
+ }
+
+ $container = $this->server->tree->getNodeForPath($containerPath);
+ if (!$container instanceof Directory) {
+ throw new Forbidden('BDELETE target must be a collection');
+ }
+
+ $contentType = strtolower(trim(explode(';', $request->getHeader('Content-Type') ?? '')[0]));
+ if (!in_array($contentType, ['application/xml', 'text/xml'], true)) {
+ throw new UnsupportedMediaType('BDELETE requires an XML request body');
+ }
+
+ $body = $request->getBody();
+ $body = is_resource($body) ? stream_get_contents($body, self::MAX_BODY_BYTES + 1) : $body;
+ if (!is_string($body) || strlen($body) > self::MAX_BODY_BYTES) {
+ $response->setStatus(413);
+ $response->setHeader('Content-Length', '0');
+ return false;
+ }
+
+ $targets = $this->parseTargets($body, $containerPath);
+ $failures = [];
+ $stopped = false;
+ foreach ($targets as $target) {
+ if ($stopped) {
+ $failures[] = ['href' => $target['href'], 'status' => 424];
+ continue;
+ }
+
+ $status = $this->deleteTarget($target['path']);
+ if ($status !== 204) {
+ $failures[] = ['href' => $target['href'], 'status' => $status];
+ $stopped = true;
+ }
+ }
+
+ $response->setHeader('Cache-Control', 'no-store');
+ if ($failures === []) {
+ $response->setStatus(204);
+ $response->setHeader('Content-Length', '0');
+ return false;
+ }
+
+ $response->setStatus(207);
+ $response->setHeader('Content-Type', 'application/xml; charset=utf-8');
+ $response->setBody($this->buildMultiStatus($failures));
+ return false;
+ }
+
+ private function isUserFilesPath(string $path): bool {
+ $path = trim($path, '/');
+ $root = 'files/' . $this->userId;
+ return $path === $root || str_starts_with($path, $root . '/');
+ }
+
+ /**
+ * @return list
+ */
+ private function parseTargets(string $body, string $containerPath): array {
+ if (stripos($body, 'server->xml->expect('{DAV:}delete', $body);
+ } catch (ParseException) {
+ throw new BadRequest('Invalid BDELETE XML body');
+ }
+
+ if ($elements === null) {
+ $elements = [];
+ }
+ if (!is_array($elements)) {
+ throw new BadRequest('BDELETE body must contain DAV: target elements');
+ }
+
+ $hrefs = [];
+ foreach ($elements as $target) {
+ if (!is_array($target) || ($target['name'] ?? null) !== '{DAV:}target') {
+ throw new BadRequest('BDELETE body may only contain DAV: target elements');
+ }
+
+ $targetValue = $target['value'] ?? null;
+ if (!is_array($targetValue) || $targetValue === []) {
+ throw new BadRequest('DAV: target must contain at least one DAV: href');
+ }
+
+ foreach ($targetValue as $hrefElement) {
+ if (!is_array($hrefElement) || ($hrefElement['name'] ?? null) !== '{DAV:}href'
+ || !is_string($hrefElement['value'] ?? null)) {
+ throw new BadRequest('DAV: target may only contain DAV: href elements');
+ }
+
+ $href = trim($hrefElement['value']);
+ if ($href === '') {
+ throw new BadRequest('DAV: href must not be empty');
+ }
+ $hrefs[] = $href;
+ }
+ }
+
+ if (count($hrefs) < 1 || count($hrefs) > self::MAX_FILES) {
+ throw new BadRequest('BDELETE requires between 1 and 100 target hrefs');
+ }
+
+ $targets = [];
+ $paths = [];
+ foreach ($hrefs as $href) {
+ $path = $this->resolveHref($containerPath, $href);
+ if (isset($paths[$path])) {
+ throw new BadRequest('Duplicate BDELETE targets are not allowed');
+ }
+ $paths[$path] = true;
+ $targets[] = ['href' => $href, 'path' => $path];
+ }
+ return $targets;
+ }
+
+ private function resolveHref(string $containerPath, string $href): string {
+ if (strlen($href) > self::MAX_HREF_BYTES
+ || str_starts_with($href, '/')
+ || str_contains($href, '?')
+ || str_contains($href, '#')
+ || preg_match('/[\x00-\x1f\x7f\\\\]/', $href)
+ ) {
+ throw new BadRequest('BDELETE href must be a relative resource path');
+ }
+
+ $decoded = [];
+ foreach (explode('/', $href) as $segment) {
+ if ($segment === '' || preg_match('/%(?![0-9A-Fa-f]{2})/', $segment)) {
+ throw new BadRequest('Invalid BDELETE href');
+ }
+ $segment = rawurldecode($segment);
+ if ($segment === '' || $segment === '.' || $segment === '..'
+ || str_contains($segment, '/') || str_contains($segment, '\\')
+ || preg_match('/[\x00-\x1f\x7f]/', $segment)
+ || !mb_check_encoding($segment, 'UTF-8')
+ ) {
+ throw new BadRequest('Invalid BDELETE href');
+ }
+ $decoded[] = $segment;
+ }
+
+ $path = $containerPath . '/' . implode('/', $decoded);
+ if (strlen($path) > self::MAX_HREF_BYTES) {
+ throw new BadRequest('BDELETE target path is too long');
+ }
+ return $path;
+ }
+
+ private function deleteTarget(string $path): int {
+ try {
+ $node = $this->server->tree->getNodeForPath($path);
+ if (!$node instanceof File || $node->getInternalPath() === '') {
+ return 403;
+ }
+
+ if (!$this->server->emit('beforeUnbind', [$path])) {
+ return 403;
+ }
+ $this->server->tree->delete($path);
+ $this->server->emit('afterUnbind', [$path]);
+ return 204;
+ } catch (Exception $exception) {
+ $status = $exception->getHTTPCode();
+ return $status >= 400 && $status <= 599 ? $status : 500;
+ } catch (\Throwable $exception) {
+ $this->logger->error('BDELETE target failed', [
+ 'path' => $path,
+ 'exception' => $exception,
+ ]);
+ return 500;
+ }
+ }
+
+ /**
+ * @param list $failures
+ */
+ private function buildMultiStatus(array $failures): string {
+ $writer = $this->server->xml->getWriter();
+ $writer->openMemory();
+ $writer->setIndent(true);
+ $writer->startDocument();
+ $writer->startElement('{DAV:}multistatus');
+
+ foreach ($failures as $failure) {
+ $writer->startElement('{DAV:}response');
+ $writer->writeElement('{DAV:}href', $failure['href']);
+ $writer->writeElement('{DAV:}status', $this->statusLine($failure['status']));
+ $writer->endElement();
+ }
+
+ $writer->endElement();
+ return $writer->outputMemory();
+ }
+
+ private function statusLine(int $status): string {
+ $reason = match ($status) {
+ 400 => 'Bad Request',
+ 401 => 'Unauthorized',
+ 403 => 'Forbidden',
+ 404 => 'Not Found',
+ 405 => 'Method Not Allowed',
+ 409 => 'Conflict',
+ 412 => 'Precondition Failed',
+ 413 => 'Content Too Large',
+ 415 => 'Unsupported Media Type',
+ 423 => 'Locked',
+ 424 => 'Failed Dependency',
+ 429 => 'Too Many Requests',
+ 500 => 'Internal Server Error',
+ 503 => 'Service Unavailable',
+ 507 => 'Insufficient Storage',
+ default => 'Error',
+ };
+ return 'HTTP/1.1 ' . $status . ' ' . $reason;
+ }
+}
diff --git a/apps/dav/lib/Capabilities.php b/apps/dav/lib/Capabilities.php
index e710ea3b3d1b1..fccc2683a9900 100644
--- a/apps/dav/lib/Capabilities.php
+++ b/apps/dav/lib/Capabilities.php
@@ -9,6 +9,7 @@
namespace OCA\DAV;
+use OCA\DAV\BulkDelete\BulkDeletePlugin;
use OCP\Capabilities\ICapability;
use OCP\IConfig;
use OCP\User\IAvailabilityCoordinator;
@@ -21,7 +22,7 @@ public function __construct(
}
/**
- * @return array{dav: array{chunking: string, public_shares_chunking: bool, search_supports_creation_time: bool, search_supports_upload_time: bool, search_supports_last_activity: bool, bulkupload?: string, absence-supported?: bool, absence-replacement?: bool}}
+ * @return array{dav: array{chunking: string, public_shares_chunking: bool, search_supports_creation_time: bool, search_supports_upload_time: bool, search_supports_last_activity: bool, bulkupload?: string, bulk_delete?: array{version: string, max_files: int}, absence-supported?: bool, absence-replacement?: bool}}
*/
#[\Override]
public function getCapabilities() {
@@ -37,6 +38,12 @@ public function getCapabilities() {
if ($this->config->getSystemValueBool('bulkupload.enabled', true)) {
$capabilities['dav']['bulkupload'] = '1.0';
}
+ if ($this->config->getSystemValueBool('bulk_delete.enabled', true)) {
+ $capabilities['dav']['bulk_delete'] = [
+ 'version' => '1.0',
+ 'max_files' => BulkDeletePlugin::MAX_FILES,
+ ];
+ }
if ($this->coordinator->isEnabled()) {
$capabilities['dav']['absence-supported'] = true;
$capabilities['dav']['absence-replacement'] = true;
diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php
index ce5964e1c10f7..e621bba4ab246 100644
--- a/apps/dav/lib/Server.php
+++ b/apps/dav/lib/Server.php
@@ -10,6 +10,7 @@
use OC\Files\Filesystem;
use OCA\DAV\AppInfo\PluginManager;
+use OCA\DAV\BulkDelete\BulkDeletePlugin;
use OCA\DAV\BulkUpload\BulkUploadPlugin;
use OCA\DAV\CalDAV\BirthdayCalendar\EnablePlugin;
use OCA\DAV\CalDAV\BirthdayService;
@@ -386,6 +387,9 @@ public function __construct(
$view,
\OCP\Server::get(IFilesMetadataManager::class)
));
+ if ($config->getSystemValueBool('bulk_delete.enabled', true)) {
+ $this->server->addPlugin(new BulkDeletePlugin($user->getUID(), $logger));
+ }
$this->server->addPlugin(
new BulkUploadPlugin(
$userFolder,
diff --git a/apps/dav/tests/unit/BulkDelete/BulkDeletePluginTest.php b/apps/dav/tests/unit/BulkDelete/BulkDeletePluginTest.php
new file mode 100644
index 0000000000000..ebab6d6dc6a17
--- /dev/null
+++ b/apps/dav/tests/unit/BulkDelete/BulkDeletePluginTest.php
@@ -0,0 +1,277 @@
+tree = $this->createMock(Tree::class);
+ $this->tree->method('getNodeForPath')->willReturnCallback(function (string $path) {
+ if (!isset($this->nodes[$path])) {
+ throw new NotFound();
+ }
+ return $this->nodes[$path];
+ });
+ $this->tree->method('delete')->willReturnCallback(function (string $path): void {
+ $this->nodes[$path]->delete();
+ $this->deleted[] = $path;
+ unset($this->nodes[$path]);
+ });
+ $this->nodes['files/alice'] = $this->createMock(Directory::class);
+ $this->server = new Server($this->tree);
+ $this->server->setBaseUri('/nextcloud/remote.php/dav/');
+ $this->plugin = new BulkDeletePlugin('alice', new NullLogger());
+ $this->server->addPlugin($this->plugin);
+ }
+
+ private function addFile(string $path): File&MockObject {
+ $file = $this->createMock(File::class);
+ $file->method('getInternalPath')->willReturn('files/' . $path);
+ $this->nodes['files/alice/' . $path] = $file;
+ return $file;
+ }
+
+ /** @param string[] $hrefs */
+ private function request(array $hrefs, string $container = 'files/alice'): Response {
+ $writer = (new Service())->getWriter();
+ $writer->openMemory();
+ $writer->startDocument();
+ $writer->startElement('{DAV:}delete');
+ $writer->startElement('{DAV:}target');
+ foreach ($hrefs as $href) {
+ $writer->writeElement('{DAV:}href', $href);
+ }
+ $writer->endElement();
+ $writer->endElement();
+ $body = $writer->outputMemory();
+
+ $request = new Request('BDELETE', '/nextcloud/remote.php/dav/' . $container . '/', [
+ 'Content-Type' => 'application/xml; charset=utf-8',
+ ], $body);
+ $request->setBaseUrl($this->server->getBaseUri());
+ $response = new Response();
+ $this->server->httpRequest = $request;
+ $this->server->httpResponse = $response;
+ $this->server->invokeMethod($request, $response, false);
+ self::assertSame($request, $this->server->httpRequest);
+ self::assertSame($response, $this->server->httpResponse);
+ return $response;
+ }
+
+ /** @return array */
+ private function failures(Response $response): array {
+ self::assertSame(207, $response->getStatus());
+ $elements = (new Service())->expect('{DAV:}multistatus', $response->getBodyAsString());
+ self::assertIsArray($elements);
+ $failures = [];
+ foreach ($elements as $item) {
+ self::assertSame('{DAV:}response', $item['name']);
+ self::assertIsArray($item['value']);
+ $href = null;
+ $status = null;
+ foreach ($item['value'] as $child) {
+ if ($child['name'] === '{DAV:}href') {
+ $href = $child['value'];
+ } elseif ($child['name'] === '{DAV:}status') {
+ $status = $child['value'];
+ }
+ }
+ self::assertIsString($href);
+ self::assertIsString($status);
+ self::assertMatchesRegularExpression('/^HTTP\/1\.1 \d{3} /', $status);
+ $failures[$href] = (int)substr($status, 9, 3);
+ }
+ return $failures;
+ }
+
+ public function testUsesSingleRequestAndDavUnbindLifecycle(): void {
+ $this->addFile('one.txt');
+ $this->addFile('two.txt');
+ $before = [];
+ $after = [];
+ $methodCalls = 0;
+ $this->server->on('beforeMethod:BDELETE', function (Request $request) use (&$methodCalls): void {
+ self::assertSame($request, $this->server->httpRequest);
+ $methodCalls++;
+ });
+ $this->server->on('beforeUnbind', function (string $path) use (&$before): void {
+ $before[] = $path;
+ });
+ $this->server->on('afterUnbind', function (string $path) use (&$after): void {
+ $after[] = $path;
+ });
+
+ $response = $this->request(['one.txt', 'two.txt']);
+ self::assertSame(204, $response->getStatus());
+ self::assertSame(1, $methodCalls);
+ self::assertSame(['files/alice/one.txt', 'files/alice/two.txt'], $before);
+ self::assertSame($before, $this->deleted);
+ self::assertSame($before, $after);
+ }
+
+ public function testFailureStopsBatchAndReportsFailedDependency(): void {
+ $this->addFile('one');
+ $this->addFile('two')->method('delete')->willThrowException(new Forbidden());
+ $this->addFile('three')->expects(self::never())->method('delete');
+
+ $response = $this->request(['one', 'two', 'three']);
+ self::assertSame([
+ 'two' => 403,
+ 'three' => 424,
+ ], $this->failures($response));
+ self::assertSame(['files/alice/one'], $this->deleted);
+ }
+
+ public function testMissingFileIsReportedInMultistatus(): void {
+ $response = $this->request(['missing']);
+ self::assertSame(['missing' => 404], $this->failures($response));
+ }
+
+ public function testFoldersAreRejected(): void {
+ $this->nodes['files/alice/folder'] = $this->createMock(Directory::class);
+ $response = $this->request(['folder']);
+ self::assertSame(['folder' => 403], $this->failures($response));
+ }
+
+ public function testMountRootFileIsRejected(): void {
+ $file = $this->createMock(File::class);
+ $file->method('getInternalPath')->willReturn('');
+ $file->expects(self::never())->method('delete');
+ $this->nodes['files/alice/shared-file'] = $file;
+ $response = $this->request(['shared-file']);
+ self::assertSame(['shared-file' => 403], $this->failures($response));
+ }
+
+ public function testSilentUnbindVetoIsNotReportedAsSuccess(): void {
+ $this->addFile('one')->expects(self::never())->method('delete');
+ $this->server->on('beforeUnbind', static fn (): bool => false);
+ $response = $this->request(['one']);
+ self::assertSame(['one' => 403], $this->failures($response));
+ }
+
+ public function testThrowableDoesNotLeakDetails(): void {
+ $this->addFile('one')->method('delete')->willThrowException(new \RuntimeException('secret-storage-detail'));
+ $response = $this->request(['one']);
+ self::assertStringNotContainsString('secret-storage-detail', $response->getBodyAsString());
+ self::assertSame(['one' => 500], $this->failures($response));
+ }
+
+ public function testUnicodeAndLiteralPercentAreDecodedExactlyOnce(): void {
+ $names = ['café + #.txt', '日本語.txt', 'literal%2Fname.txt', 'emoji-😀.txt'];
+ $hrefs = [];
+ foreach ($names as $name) {
+ $this->addFile('folder/' . $name);
+ $hrefs[] = 'folder/' . rawurlencode($name);
+ }
+ $response = $this->request($hrefs);
+ self::assertSame(204, $response->getStatus());
+ self::assertSame(array_map(static fn (string $name): string => 'files/alice/folder/' . $name, $names), $this->deleted);
+ }
+
+ public function testFailureResponsePreservesEncodedHref(): void {
+ $this->addFile('café + #.txt')->method('delete')->willThrowException(new Forbidden());
+ $href = 'caf%C3%A9%20%2B%20%23.txt';
+ $response = $this->request([$href]);
+ self::assertSame([$href => 403], $this->failures($response));
+ }
+
+ public function testTargetIsAlwaysRelativeToRequestCollection(): void {
+ $this->nodes['files/alice/folder'] = $this->createMock(Directory::class);
+ $this->addFile('folder/files/bob/one');
+ $response = $this->request(['files/bob/one'], 'files/alice/folder');
+ self::assertSame(204, $response->getStatus());
+ self::assertSame(['files/alice/folder/files/bob/one'], $this->deleted);
+ }
+
+ #[DataProvider('invalidBodies')]
+ public function testInvalidBodyHasNoSideEffects(string $body): void {
+ $this->tree->expects(self::never())->method('delete');
+ $request = new Request('BDELETE', '/nextcloud/remote.php/dav/files/alice/', [
+ 'Content-Type' => 'application/xml',
+ ], $body);
+ $request->setBaseUrl($this->server->getBaseUri());
+ $this->expectException(BadRequest::class);
+ $this->plugin->httpBulkDelete($request, new Response());
+ }
+
+ public static function invalidBodies(): array {
+ $href = static fn (string $value): string => '' . $value . '';
+ $wrap = static fn (string $value): string => '' . $value . '';
+ return [
+ 'empty target' => [$wrap('')],
+ 'absolute path' => [$wrap($href('/one'))],
+ 'traversal' => [$wrap($href('../one'))],
+ 'encoded traversal' => [$wrap($href('%2e%2e/one'))],
+ 'double slash' => [$wrap($href('folder//one'))],
+ 'query' => [$wrap($href('one?x=1'))],
+ 'fragment' => [$wrap($href('one#x'))],
+ 'bad percent' => [$wrap($href('one%2'))],
+ 'encoded slash' => [$wrap($href('one%2Ftwo'))],
+ 'duplicate' => [$wrap($href('one') . $href('one'))],
+ 'wrong root' => ['' . $href('one') . ''],
+ 'unexpected element' => [$wrap('' . $href('one'))],
+ 'nested href element' => [$wrap('')],
+ 'doctype' => [']>&x;'],
+ ];
+ }
+
+ public function testContentTypeIsRequired(): void {
+ $request = new Request('BDELETE', '/nextcloud/remote.php/dav/files/alice/', [], '');
+ $request->setBaseUrl($this->server->getBaseUri());
+ $this->expectException(UnsupportedMediaType::class);
+ $this->plugin->httpBulkDelete($request, new Response());
+ }
+
+ public function testOversizedStreamIsRejectedWithoutDeletion(): void {
+ $body = fopen('php://temp', 'w+');
+ fwrite($body, str_repeat('x', BulkDeletePlugin::MAX_BODY_BYTES + 1));
+ rewind($body);
+ $request = new Request('BDELETE', '/nextcloud/remote.php/dav/files/alice/', ['Content-Type' => 'application/xml'], $body);
+ $request->setBaseUrl($this->server->getBaseUri());
+ $response = new Response();
+ try {
+ self::assertFalse($this->plugin->httpBulkDelete($request, $response));
+ self::assertSame(413, $response->getStatus());
+ self::assertSame([], $this->deleted);
+ } finally {
+ fclose($body);
+ }
+ }
+
+ public function testOptionsAdvertisesBDeleteOnUserFilesTree(): void {
+ $request = new Request('OPTIONS', '/nextcloud/remote.php/dav/files/alice/');
+ $request->setBaseUrl($this->server->getBaseUri());
+ $response = new Response();
+ $this->server->invokeMethod($request, $response, false);
+ self::assertStringContainsString('BDELETE', $response->getHeader('Allow') ?? '');
+ }
+}
diff --git a/apps/dav/tests/unit/CapabilitiesTest.php b/apps/dav/tests/unit/CapabilitiesTest.php
index 1c8edd250ca14..9d1ab52b4faf6 100644
--- a/apps/dav/tests/unit/CapabilitiesTest.php
+++ b/apps/dav/tests/unit/CapabilitiesTest.php
@@ -19,10 +19,12 @@
class CapabilitiesTest extends TestCase {
public function testGetCapabilities(): void {
$config = $this->createMock(IConfig::class);
- $config->expects($this->once())
+ $config->expects($this->exactly(2))
->method('getSystemValueBool')
- ->with('bulkupload.enabled', $this->isType('bool'))
- ->willReturn(false);
+ ->willReturnMap([
+ ['bulkupload.enabled', true, false],
+ ['bulk_delete.enabled', true, false],
+ ]);
$coordinator = $this->createMock(IAvailabilityCoordinator::class);
$coordinator->expects($this->once())
->method('isEnabled')
@@ -42,10 +44,12 @@ public function testGetCapabilities(): void {
public function testGetCapabilitiesWithBulkUpload(): void {
$config = $this->createMock(IConfig::class);
- $config->expects($this->once())
+ $config->expects($this->exactly(2))
->method('getSystemValueBool')
- ->with('bulkupload.enabled', $this->isType('bool'))
- ->willReturn(true);
+ ->willReturnMap([
+ ['bulkupload.enabled', true, true],
+ ['bulk_delete.enabled', true, false],
+ ]);
$coordinator = $this->createMock(IAvailabilityCoordinator::class);
$coordinator->expects($this->once())
->method('isEnabled')
@@ -66,10 +70,12 @@ public function testGetCapabilitiesWithBulkUpload(): void {
public function testGetCapabilitiesWithAbsence(): void {
$config = $this->createMock(IConfig::class);
- $config->expects($this->once())
+ $config->expects($this->exactly(2))
->method('getSystemValueBool')
- ->with('bulkupload.enabled', $this->isType('bool'))
- ->willReturn(false);
+ ->willReturnMap([
+ ['bulkupload.enabled', true, false],
+ ['bulk_delete.enabled', true, false],
+ ]);
$coordinator = $this->createMock(IAvailabilityCoordinator::class);
$coordinator->expects($this->once())
->method('isEnabled')
@@ -88,4 +94,19 @@ public function testGetCapabilitiesWithAbsence(): void {
];
$this->assertSame($expected, $capabilities->getCapabilities());
}
+
+ public function testGetCapabilitiesWithBulkDelete(): void {
+ $config = $this->createMock(IConfig::class);
+ $config->method('getSystemValueBool')->willReturnMap([
+ ['bulkupload.enabled', true, false],
+ ['bulk_delete.enabled', true, true],
+ ]);
+ $coordinator = $this->createMock(IAvailabilityCoordinator::class);
+ $coordinator->method('isEnabled')->willReturn(false);
+ $capabilities = new Capabilities($config, $coordinator);
+ $this->assertSame([
+ 'version' => '1.0',
+ 'max_files' => 100,
+ ], $capabilities->getCapabilities()['dav']['bulk_delete']);
+ }
}
diff --git a/apps/files/src/actions/deleteAction.ts b/apps/files/src/actions/deleteAction.ts
index b5e06f85c7f15..2fcb256acdcf9 100644
--- a/apps/files/src/actions/deleteAction.ts
+++ b/apps/files/src/actions/deleteAction.ts
@@ -14,6 +14,7 @@ import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import PQueue from 'p-queue'
import { logger } from '../utils/logger.ts'
+import { deleteNodesInBatches } from './deleteBatchUtils.ts'
import { askConfirmation, canDisconnectOnly, canUnshareOnly, deleteNode, displayName, shouldAskForConfirmation } from './deleteUtils.ts'
// TODO: once the files app is migrated to the new frontend use the import instead:
@@ -89,6 +90,11 @@ export const action: IFileAction = {
return Promise.all(nodes.map(() => null))
}
+ const batchResult = deleteNodesInBatches(nodes, view, queue)
+ if (batchResult !== null) {
+ return batchResult
+ }
+
// Map each node to a promise that resolves with the result of exec(node)
const promises = nodes.map((node) => {
// Create a promise that resolves with the result of exec(node)
diff --git a/apps/files/src/actions/deleteBatchUtils.spec.ts b/apps/files/src/actions/deleteBatchUtils.spec.ts
new file mode 100644
index 0000000000000..4506b36dd0164
--- /dev/null
+++ b/apps/files/src/actions/deleteBatchUtils.spec.ts
@@ -0,0 +1,103 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode, IView } from '@nextcloud/files'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import axios from '@nextcloud/axios'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { emit } from '@nextcloud/event-bus'
+import { File, Folder, Permission } from '@nextcloud/files'
+import PQueue from 'p-queue'
+import { beforeEach, expect, test, vi } from 'vitest'
+import { deleteNodesInBatches } from './deleteBatchUtils.ts'
+
+vi.mock('@nextcloud/auth')
+vi.mock('@nextcloud/axios')
+vi.mock('@nextcloud/capabilities')
+vi.mock('@nextcloud/event-bus')
+vi.mock('@nextcloud/router', () => ({ generateRemoteUrl: () => 'http://nextcloud.local/remote.php/dav' }))
+
+const view = { id: 'files', name: 'Files' } as IView
+let queue: PQueue
+
+function file(id: number, name = `file-${id}.txt`): File {
+ return new File({
+ id,
+ source: `http://nextcloud.local/remote.php/dav/files/alice/${name}`,
+ owner: 'alice',
+ root: '/files/alice',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+}
+
+beforeEach(() => {
+ vi.resetAllMocks()
+ queue = new PQueue({ concurrency: 5 })
+ vi.mocked(getCurrentUser).mockReturnValue({ uid: 'alice' } as ReturnType)
+ vi.mocked(getCapabilities).mockReturnValue({ files: { undelete: true }, dav: { bulk_delete: { version: '1.0', max_files: 100 } } })
+ vi.mocked(axios.request).mockResolvedValue({ status: 204, data: '' })
+})
+
+test('uses one BDELETE request and only confirmed deletion events', async () => {
+ const nodes = [file(1), file(2)]
+ expect(await deleteNodesInBatches(nodes, view, queue)).toEqual([true, true])
+ expect(axios.request).toHaveBeenCalledTimes(1)
+ expect(axios.delete).not.toHaveBeenCalled()
+ expect(axios.request).toHaveBeenCalledWith({
+ method: 'BDELETE',
+ url: 'http://nextcloud.local/remote.php/dav/files/alice/',
+ data: 'file-1.txtfile-2.txt',
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
+ })
+ expect(emit).toHaveBeenCalledWith('files:node:deleted', nodes[0])
+ expect(emit).toHaveBeenCalledWith('files:node:deleted', nodes[1])
+})
+
+test('does not use BDELETE without the advertised capability', () => {
+ vi.mocked(getCapabilities).mockReturnValue({ files: { undelete: true } })
+ expect(deleteNodesInBatches([file(1), file(2)], view, queue)).toBeNull()
+ expect(axios.request).not.toHaveBeenCalled()
+})
+
+test('keeps permanent deletion on the existing path', () => {
+ expect(deleteNodesInBatches([file(1), file(2)], { ...view, id: 'trashbin' }, queue)).toBeNull()
+ vi.mocked(getCapabilities).mockReturnValue({ files: { undelete: false }, dav: { bulk_delete: { version: '1.0', max_files: 100 } } })
+ expect(deleteNodesInBatches([file(1), file(2)], view, queue)).toBeNull()
+})
+
+test('keeps mixed file-folder selections on the existing path', () => {
+ const folder = new Folder({ id: 3, source: 'http://nextcloud.local/remote.php/dav/files/alice/folder', root: '/files/alice', owner: 'alice', permissions: Permission.ALL })
+ expect(deleteNodesInBatches([file(1), folder], view, queue)).toBeNull()
+})
+
+test('keeps shared and external mount roots on the existing path', () => {
+ for (const mountType of ['shared', 'external']) {
+ const mounted = file(1)
+ mounted.attributes['is-mount-root'] = true
+ mounted.attributes['mount-type'] = mountType
+ expect(deleteNodesInBatches([mounted, file(2)], view, queue)).toBeNull()
+ }
+})
+
+test('rejects foreign origins, user roots and public share paths', () => {
+ for (const source of [
+ 'https://other.invalid/remote.php/dav/files/alice/one',
+ 'http://nextcloud.local/remote.php/dav/files/bob/one',
+ 'http://nextcloud.local/remote.php/dav/public-files/token/one',
+ ]) {
+ const node = { ...file(1), type: file(1).type, permissions: Permission.ALL, fileid: 1, attributes: {}, encodedSource: source } as INode
+ expect(deleteNodesInBatches([node, file(2)], view, queue)).toBeNull()
+ }
+})
+
+test('does not retry or fall back after a BDELETE timeout', async () => {
+ vi.mocked(axios.request).mockRejectedValue(new Error('timeout'))
+ expect(await deleteNodesInBatches([file(1), file(2)], view, queue)).toEqual([false, false])
+ expect(axios.request).toHaveBeenCalledTimes(1)
+ expect(axios.delete).not.toHaveBeenCalled()
+ expect(emit).not.toHaveBeenCalled()
+})
diff --git a/apps/files/src/actions/deleteBatchUtils.ts b/apps/files/src/actions/deleteBatchUtils.ts
new file mode 100644
index 0000000000000..eb85a2e798317
--- /dev/null
+++ b/apps/files/src/actions/deleteBatchUtils.ts
@@ -0,0 +1,83 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode, IView } from '@nextcloud/files'
+import type PQueue from 'p-queue'
+import type { BulkDeleteItem } from '../services/bulkDelete.ts'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import axios from '@nextcloud/axios'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { emit } from '@nextcloud/event-bus'
+import { FileType, Permission } from '@nextcloud/files'
+import { generateRemoteUrl } from '@nextcloud/router'
+import { createBDeleteBody, isBulkDeleteItem, parseBDeleteResponse, runBulkDelete } from '../services/bulkDelete.ts'
+import { logger } from '../utils/logger.ts'
+
+interface BulkDeleteCapabilities {
+ files?: { undelete?: boolean }
+ dav?: { bulk_delete?: { version?: string, max_files?: number } }
+}
+
+/**
+ * Return null before sending any request when the existing action must be used.
+ * Mixed selections, folders, trash entries, public shares and mount roots keep
+ * their existing semantics. node.source must belong to this user's DAV root.
+ */
+export function deleteNodesInBatches(nodes: INode[], view: IView, queue: PQueue): Promise | null {
+ const capabilities = getCapabilities() as BulkDeleteCapabilities
+ const support = capabilities?.dav?.bulk_delete
+ const user = getCurrentUser()
+ if (nodes.length < 2 || view.id === 'trashbin' || capabilities?.files?.undelete !== true
+ || support?.version !== '1.0' || !Number.isInteger(support.max_files) || support.max_files! < 1 || !user) {
+ return null
+ }
+
+ const davRoot = generateRemoteUrl('dav').replace(/\/$/, '')
+ const userRoot = new URL(`${davRoot}/files/${encodeURIComponent(user.uid)}/`, window.location.href)
+ const files: BulkDeleteItem[] = []
+ try {
+ for (const node of nodes) {
+ if (node.type !== FileType.File || node.attributes['is-mount-root'] === true
+ || !(node.permissions & Permission.DELETE) || typeof node.fileid !== 'number') {
+ return null
+ }
+ const source = new URL(node.encodedSource, window.location.href)
+ if (source.origin !== userRoot.origin || !source.pathname.startsWith(userRoot.pathname)
+ || source.search !== '' || source.hash !== '') {
+ return null
+ }
+ const file = { path: '/' + decodeURIComponent(source.pathname.slice(userRoot.pathname.length)), fileId: node.fileid }
+ if (!isBulkDeleteItem(file)) {
+ return null
+ }
+ files.push(file)
+ }
+ } catch {
+ return null
+ }
+
+ return runBulkDelete(files, {
+ batchSize: Math.min(100, support.max_files!),
+ concurrency: 5,
+ async request(batch) {
+ return queue.add(async () => {
+ const response = await axios.request({
+ method: 'BDELETE',
+ url: userRoot.toString(),
+ data: createBDeleteBody(batch),
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
+ })
+ return parseBDeleteResponse(response.status, response.data, batch)
+ })
+ },
+ onDeleted(index) {
+ emit('files:node:deleted', nodes[index]!)
+ },
+ onError(error) {
+ logger.error('BDELETE failed; refresh the file list before retrying', { error })
+ },
+ })
+}
diff --git a/apps/files/src/services/bulkDelete.spec.ts b/apps/files/src/services/bulkDelete.spec.ts
new file mode 100644
index 0000000000000..164c6026931cc
--- /dev/null
+++ b/apps/files/src/services/bulkDelete.spec.ts
@@ -0,0 +1,141 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { BulkDeleteItem } from './bulkDelete.ts'
+
+import { expect, test, vi } from 'vitest'
+import { createBDeleteBody, getBDeleteHref, isBulkDeleteItem, parseBDeleteResponse, runBulkDelete, validateBulkDeleteResponse } from './bulkDelete.ts'
+
+function files(count: number): BulkDeleteItem[] {
+ return Array.from({ length: count }, (_, index) => ({ path: `/file-${index}.txt`, fileId: index + 1 }))
+}
+
+function success(batch: BulkDeleteItem[]) {
+ return { results: batch.map((file) => ({ ...file, status: 204, attempted: true })), stopped: false }
+}
+
+function multiStatus(entries: Array<[string, number]>): string {
+ const responses = entries.map(([href, status]) => `${href}HTTP/1.1 ${status} Error`).join('')
+ return `${responses}`
+}
+
+test.each([[385, 4], [970, 10], [1100, 11]])('%i files use %i BDELETE requests', async (count, requests) => {
+ const request = vi.fn(async (batch: BulkDeleteItem[]) => success(batch))
+ const onDeleted = vi.fn()
+ const result = await runBulkDelete(files(count), { batchSize: 100, request, onDeleted, onError: vi.fn() })
+ expect(request).toHaveBeenCalledTimes(requests)
+ expect(onDeleted).toHaveBeenCalledTimes(count)
+ expect(result).toEqual(Array(count).fill(true))
+ expect(request.mock.calls.every(([batch]) => batch.length <= 100)).toBe(true)
+})
+
+test('never has more than five requests in flight', async () => {
+ let active = 0
+ let maximum = 0
+ await runBulkDelete(files(1100), {
+ batchSize: 100,
+ async request(batch) {
+ active++
+ maximum = Math.max(maximum, active)
+ await new Promise((resolve) => setTimeout(resolve, 1))
+ active--
+ return success(batch)
+ },
+ onDeleted: vi.fn(),
+ onError: vi.fn(),
+ })
+ expect(maximum).toBe(5)
+})
+
+test('creates an Exchange-style DAV delete body', () => {
+ const batch = [
+ { path: '/folder/café + #.txt', fileId: 1 },
+ { path: '/literal%2Fname.txt', fileId: 2 },
+ ]
+ const body = createBDeleteBody(batch)
+ expect(body).toContain('')
+ expect(body).toContain('folder/caf%C3%A9%20%2B%20%23.txt')
+ expect(body).toContain('literal%252Fname.txt')
+})
+
+test('204 means every target was deleted', () => {
+ const batch = files(2)
+ expect(parseBDeleteResponse(204, '', batch)).toEqual(success(batch))
+})
+
+test('207 maps the failed target and unattempted suffix', () => {
+ const batch = files(3)
+ const response = parseBDeleteResponse(207, multiStatus([
+ [getBDeleteHref(batch[1]!), 403],
+ [getBDeleteHref(batch[2]!), 424],
+ ]), batch)
+ expect(response).toEqual({
+ results: [
+ { ...batch[0]!, status: 204, attempted: true },
+ { ...batch[1]!, status: 403, attempted: true },
+ { ...batch[2]!, status: 424, attempted: false },
+ ],
+ stopped: true,
+ })
+})
+
+test('malformed or mismatched multistatus cannot remove visible nodes', () => {
+ const batch = files(2)
+ expect(() => parseBDeleteResponse(207, '', batch)).toThrow()
+ expect(() => parseBDeleteResponse(207, multiStatus([['other.txt', 403]]), batch)).toThrow()
+ expect(() => parseBDeleteResponse(200, '', batch)).toThrow()
+})
+
+test('partial failure emits success only for confirmed items and stops unsent batches', async () => {
+ const request = vi.fn(async (batch: BulkDeleteItem[]) => ({
+ results: batch.map((file, index) => ({ ...file, status: [204, 403, 424][index], attempted: index < 2 })),
+ stopped: true,
+ }))
+ const onDeleted = vi.fn()
+ const onError = vi.fn()
+ const result = await runBulkDelete(files(6), { batchSize: 3, concurrency: 1, request, onDeleted, onError })
+ expect(result).toEqual([true, false, false, false, false, false])
+ expect(request).toHaveBeenCalledTimes(1)
+ expect(onDeleted).toHaveBeenCalledTimes(1)
+ expect(onDeleted).toHaveBeenCalledWith(0)
+ expect(onError).toHaveBeenCalledTimes(1)
+})
+
+test('a lost response is never retried', async () => {
+ const request = vi.fn(async () => { throw new Error('network timeout') })
+ const onDeleted = vi.fn()
+ const result = await runBulkDelete(files(200), { batchSize: 100, concurrency: 1, request, onDeleted, onError: vi.fn() })
+ expect(result.every((value) => !value)).toBe(true)
+ expect(request).toHaveBeenCalledTimes(1)
+ expect(onDeleted).not.toHaveBeenCalled()
+})
+
+test('response identities and the stopped suffix are validated', () => {
+ const batch = files(3)
+ let response = success(batch)
+ response.results[1]!.fileId = 999
+ expect(() => validateBulkDeleteResponse(response, batch)).toThrow()
+ response = success(batch)
+ response.results[1]!.status = 403
+ response.stopped = true
+ expect(() => validateBulkDeleteResponse(response, batch)).toThrow()
+})
+
+test.each(['/', '../file', '/a/../b', '/a//b', '/a/./b', '/a\\b', '/a\u0000b'])('rejects unsafe path %s', (path) => {
+ expect(isBulkDeleteItem({ path, fileId: 1 })).toBe(false)
+})
+
+test.each(['café + #.txt', '日本語.txt', 'literal%2Fname.txt', 'emoji-😀.txt'])('preserves UTF-8 path %s', (name) => {
+ const file = { path: `/folder/${name}`, fileId: 1 }
+ expect(isBulkDeleteItem(file)).toBe(true)
+ expect(getBDeleteHref(file)).not.toContain(' ')
+})
+
+test('rejects duplicate selections before any request', async () => {
+ const request = vi.fn()
+ const batch = files(1)
+ await runBulkDelete([batch[0]!, batch[0]!], { batchSize: 100, request, onDeleted: vi.fn(), onError: vi.fn() })
+ expect(request).not.toHaveBeenCalled()
+})
diff --git a/apps/files/src/services/bulkDelete.ts b/apps/files/src/services/bulkDelete.ts
new file mode 100644
index 0000000000000..9c8c082cced30
--- /dev/null
+++ b/apps/files/src/services/bulkDelete.ts
@@ -0,0 +1,206 @@
+/*!
+ * SPDX-FileCopyrightText: 2026 Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { encodePath } from '@nextcloud/paths'
+
+export interface BulkDeleteItem {
+ path: string
+ fileId: number
+}
+
+export interface BulkDeleteResult extends BulkDeleteItem {
+ status: number
+ attempted: boolean
+}
+
+export interface BulkDeleteResponse {
+ results: BulkDeleteResult[]
+ stopped: boolean
+}
+
+export interface BulkDeleteOptions {
+ batchSize: number
+ concurrency?: number
+ request: (files: BulkDeleteItem[]) => Promise
+ onDeleted: (index: number) => void
+ onError: (error: unknown) => void
+}
+
+/** Both sides validate the same raw UTF-8, user-relative paths. */
+export function isBulkDeleteItem(value: BulkDeleteItem): boolean {
+ return Number.isSafeInteger(value.fileId) && value.fileId > 0
+ && typeof value.path === 'string' && value.path.startsWith('/')
+ && new TextEncoder().encode(value.path).length <= 4096
+ && !/[\u0000-\u001f\u007f\\]/.test(value.path)
+ && value.path.slice(1).split('/').every((part) => part !== '' && part !== '.' && part !== '..')
+}
+
+export function getBDeleteHref(file: BulkDeleteItem): string {
+ return encodePath(file.path).replace(/^\/+/, '')
+}
+
+function escapeXml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
+
+export function createBDeleteBody(files: BulkDeleteItem[]): string {
+ const hrefs = files.map((file) => `${escapeXml(getBDeleteHref(file))}`).join('')
+ return `${hrefs}`
+}
+
+/**
+ * Convert the Exchange-style BDELETE 204/207 response into the existing local
+ * result shape used by the batching scheduler.
+ */
+export function parseBDeleteResponse(status: number, body: unknown, files: BulkDeleteItem[]): BulkDeleteResponse {
+ if (status === 204) {
+ return {
+ results: files.map((file) => ({ ...file, status: 204, attempted: true })),
+ stopped: false,
+ }
+ }
+ if (status !== 207 || typeof body !== 'string') {
+ throw new Error('Invalid BDELETE response; refresh the file list before retrying')
+ }
+
+ const document = new DOMParser().parseFromString(body, 'application/xml')
+ if (document.querySelector('parsererror') !== null
+ || document.documentElement.namespaceURI !== 'DAV:'
+ || document.documentElement.localName !== 'multistatus') {
+ throw new Error('Invalid BDELETE multistatus response')
+ }
+
+ const hrefToIndex = new Map(files.map((file, index) => [getBDeleteHref(file), index]))
+ const failures = new Map()
+ for (const response of Array.from(document.documentElement.children)) {
+ if (response.namespaceURI !== 'DAV:' || response.localName !== 'response') {
+ throw new Error('Invalid BDELETE multistatus child')
+ }
+ const hrefElements = Array.from(response.children).filter((element) => element.namespaceURI === 'DAV:' && element.localName === 'href')
+ const statusElements = Array.from(response.children).filter((element) => element.namespaceURI === 'DAV:' && element.localName === 'status')
+ if (hrefElements.length !== 1 || statusElements.length !== 1) {
+ throw new Error('Invalid BDELETE multistatus item')
+ }
+ const href = hrefElements[0]!.textContent ?? ''
+ const match = /^HTTP\/1\.[01]\s+(\d{3})(?:\s|$)/.exec(statusElements[0]!.textContent ?? '')
+ const index = hrefToIndex.get(href)
+ const itemStatus = match ? Number.parseInt(match[1]!, 10) : 0
+ if (index === undefined || failures.has(index) || itemStatus < 400 || itemStatus > 599) {
+ throw new Error('BDELETE response does not match the requested files')
+ }
+ failures.set(index, itemStatus)
+ }
+ if (failures.size === 0) {
+ throw new Error('BDELETE 207 response did not contain any failures')
+ }
+
+ const firstFailure = Math.min(...failures.keys())
+ const results: BulkDeleteResult[] = files.map((file, index) => {
+ if (index < firstFailure) {
+ if (failures.has(index)) {
+ throw new Error('Invalid BDELETE failure ordering')
+ }
+ return { ...file, status: 204, attempted: true }
+ }
+ if (index === firstFailure) {
+ const itemStatus = failures.get(index)
+ if (itemStatus === undefined || itemStatus === 424) {
+ throw new Error('Invalid first BDELETE failure')
+ }
+ return { ...file, status: itemStatus, attempted: true }
+ }
+ const itemStatus = failures.get(index)
+ if (itemStatus !== 424) {
+ throw new Error('Invalid stopped BDELETE response')
+ }
+ return { ...file, status: 424, attempted: false }
+ })
+
+ return { results, stopped: true }
+}
+
+/** Reject malformed or mismatched local results before emitting success events. */
+export function validateBulkDeleteResponse(value: unknown, files: BulkDeleteItem[]): BulkDeleteResponse {
+ if (!value || typeof value !== 'object' || !('results' in value) || !Array.isArray(value.results)
+ || !('stopped' in value) || typeof value.stopped !== 'boolean' || value.results.length !== files.length) {
+ throw new Error('Invalid bulk delete response; refresh the file list before retrying')
+ }
+ let stopped = false
+ for (let index = 0; index < files.length; index++) {
+ const result = value.results[index]
+ const file = files[index]!
+ if (!result || result.path !== file.path || result.fileId !== file.fileId
+ || !Number.isInteger(result.status) || typeof result.attempted !== 'boolean') {
+ throw new Error('Bulk delete response does not match the requested files')
+ }
+ if (stopped) {
+ if (result.attempted !== false || result.status !== 424) {
+ throw new Error('Invalid bulk delete stopped-batch response')
+ }
+ } else {
+ if (result.attempted !== true || (result.status !== 204 && (result.status < 400 || result.status > 599))) {
+ throw new Error('Invalid bulk delete item status')
+ }
+ stopped = result.status !== 204
+ }
+ }
+ if (value.stopped !== stopped) {
+ throw new Error('Inconsistent bulk delete response')
+ }
+ return value as BulkDeleteResponse
+}
+
+/**
+ * Bounded workers preserve input-order results even when requests finish out of
+ * order. Never retry a BDELETE or fall back to DELETE after dispatching a batch.
+ */
+export async function runBulkDelete(files: BulkDeleteItem[], options: BulkDeleteOptions): Promise {
+ const results = files.map(() => false)
+ const batchSize = options.batchSize
+ const concurrency = options.concurrency ?? 5
+ if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 100
+ || !Number.isInteger(concurrency) || concurrency < 1 || concurrency > 5
+ || !files.every(isBulkDeleteItem)
+ || new Set(files.map((file) => file.path)).size !== files.length
+ || new Set(files.map((file) => file.fileId)).size !== files.length) {
+ options.onError(new Error('Invalid bulk delete selection or limits'))
+ return results
+ }
+
+ let nextIndex = 0
+ let stopped = false
+ async function worker(): Promise {
+ while (!stopped && nextIndex < files.length) {
+ const start = nextIndex
+ nextIndex += batchSize
+ const batch = files.slice(start, start + batchSize)
+ try {
+ const response = validateBulkDeleteResponse(await options.request(batch), batch)
+ if (response.stopped) {
+ stopped = true
+ }
+ for (let offset = 0; offset < response.results.length; offset++) {
+ if (response.results[offset]!.status === 204) {
+ results[start + offset] = true
+ options.onDeleted(start + offset)
+ }
+ }
+ if (response.stopped) {
+ options.onError(new Error('Bulk deletion stopped after a failed item; refresh before retrying'))
+ }
+ } catch (error) {
+ stopped = true
+ options.onError(error)
+ }
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(concurrency, Math.ceil(files.length / batchSize)) }, () => worker()))
+ return results
+}