From 84a8e5b1f2da14e93c67fbb1a94098f8548f783e Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Wed, 29 Oct 2025 11:07:21 +0100 Subject: [PATCH] fix(systemtags): remove duplicates, prevent and sanitize existing tags Signed-off-by: skjnldsv Assisted-by: ClaudeCode:claude-opus-4-8 --- apps/dav/lib/SystemTag/SystemTagPlugin.php | 2 +- .../composer/composer/autoload_classmap.php | 1 + .../composer/composer/autoload_static.php | 1 + apps/settings/lib/AppInfo/Application.php | 2 + .../RepairSanitizeSystemTagsAvailable.php | 45 +++ lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + lib/private/Repair.php | 2 + .../Repair/RepairSanitizeSystemTags.php | 261 ++++++++++++++++++ lib/private/SystemTag/SystemTagManager.php | 7 +- lib/public/Util.php | 20 ++ .../Repair/RepairSanitizeSystemTagsTest.php | 211 ++++++++++++++ tests/lib/UtilTest.php | 50 ++++ 13 files changed, 601 insertions(+), 3 deletions(-) create mode 100644 apps/settings/lib/SetupChecks/RepairSanitizeSystemTagsAvailable.php create mode 100644 lib/private/Repair/RepairSanitizeSystemTags.php create mode 100644 tests/lib/Repair/RepairSanitizeSystemTagsTest.php diff --git a/apps/dav/lib/SystemTag/SystemTagPlugin.php b/apps/dav/lib/SystemTag/SystemTagPlugin.php index db506e9b720cf..b5e184f553b46 100644 --- a/apps/dav/lib/SystemTag/SystemTagPlugin.php +++ b/apps/dav/lib/SystemTag/SystemTagPlugin.php @@ -164,7 +164,7 @@ private function createTag($data, $contentType = 'application/json') { throw new BadRequest('Missing "name" attribute'); } - $tagName = $data['name']; + $tagName = Util::sanitizeWordsAndEmojis($data['name']); $userVisible = true; $userAssignable = true; diff --git a/apps/settings/composer/composer/autoload_classmap.php b/apps/settings/composer/composer/autoload_classmap.php index cfa1b1ec0c11d..a526b46873f77 100644 --- a/apps/settings/composer/composer/autoload_classmap.php +++ b/apps/settings/composer/composer/autoload_classmap.php @@ -130,6 +130,7 @@ 'OCA\\Settings\\SetupChecks\\PushService' => $baseDir . '/../lib/SetupChecks/PushService.php', 'OCA\\Settings\\SetupChecks\\RandomnessSecure' => $baseDir . '/../lib/SetupChecks/RandomnessSecure.php', 'OCA\\Settings\\SetupChecks\\ReadOnlyConfig' => $baseDir . '/../lib/SetupChecks/ReadOnlyConfig.php', + 'OCA\\Settings\\SetupChecks\\RepairSanitizeSystemTagsAvailable' => $baseDir . '/../lib/SetupChecks/RepairSanitizeSystemTagsAvailable.php', 'OCA\\Settings\\SetupChecks\\SchedulingTableSize' => $baseDir . '/../lib/SetupChecks/SchedulingTableSize.php', 'OCA\\Settings\\SetupChecks\\SecurityHeaders' => $baseDir . '/../lib/SetupChecks/SecurityHeaders.php', 'OCA\\Settings\\SetupChecks\\ServerIdConfig' => $baseDir . '/../lib/SetupChecks/ServerIdConfig.php', diff --git a/apps/settings/composer/composer/autoload_static.php b/apps/settings/composer/composer/autoload_static.php index 2900792c91478..2cba33bd7dcb4 100644 --- a/apps/settings/composer/composer/autoload_static.php +++ b/apps/settings/composer/composer/autoload_static.php @@ -145,6 +145,7 @@ class ComposerStaticInitSettings 'OCA\\Settings\\SetupChecks\\PushService' => __DIR__ . '/..' . '/../lib/SetupChecks/PushService.php', 'OCA\\Settings\\SetupChecks\\RandomnessSecure' => __DIR__ . '/..' . '/../lib/SetupChecks/RandomnessSecure.php', 'OCA\\Settings\\SetupChecks\\ReadOnlyConfig' => __DIR__ . '/..' . '/../lib/SetupChecks/ReadOnlyConfig.php', + 'OCA\\Settings\\SetupChecks\\RepairSanitizeSystemTagsAvailable' => __DIR__ . '/..' . '/../lib/SetupChecks/RepairSanitizeSystemTagsAvailable.php', 'OCA\\Settings\\SetupChecks\\SchedulingTableSize' => __DIR__ . '/..' . '/../lib/SetupChecks/SchedulingTableSize.php', 'OCA\\Settings\\SetupChecks\\SecurityHeaders' => __DIR__ . '/..' . '/../lib/SetupChecks/SecurityHeaders.php', 'OCA\\Settings\\SetupChecks\\ServerIdConfig' => __DIR__ . '/..' . '/../lib/SetupChecks/ServerIdConfig.php', diff --git a/apps/settings/lib/AppInfo/Application.php b/apps/settings/lib/AppInfo/Application.php index 09ef7c54bd966..ff1f0d222f0e8 100644 --- a/apps/settings/lib/AppInfo/Application.php +++ b/apps/settings/lib/AppInfo/Application.php @@ -67,6 +67,7 @@ use OCA\Settings\SetupChecks\PushService; use OCA\Settings\SetupChecks\RandomnessSecure; use OCA\Settings\SetupChecks\ReadOnlyConfig; +use OCA\Settings\SetupChecks\RepairSanitizeSystemTagsAvailable; use OCA\Settings\SetupChecks\SchedulingTableSize; use OCA\Settings\SetupChecks\SecurityHeaders; use OCA\Settings\SetupChecks\ServerIdConfig; @@ -211,6 +212,7 @@ public function register(IRegistrationContext $context): void { $context->registerSetupCheck(PhpOutputBuffering::class); $context->registerSetupCheck(RandomnessSecure::class); $context->registerSetupCheck(ReadOnlyConfig::class); + $context->registerSetupCheck(RepairSanitizeSystemTagsAvailable::class); $context->registerSetupCheck(SecurityHeaders::class); $context->registerSetupCheck(ServerIdConfig::class); $context->registerSetupCheck(SchedulingTableSize::class); diff --git a/apps/settings/lib/SetupChecks/RepairSanitizeSystemTagsAvailable.php b/apps/settings/lib/SetupChecks/RepairSanitizeSystemTagsAvailable.php new file mode 100644 index 0000000000000..a153251a0ea79 --- /dev/null +++ b/apps/settings/lib/SetupChecks/RepairSanitizeSystemTagsAvailable.php @@ -0,0 +1,45 @@ +l10n->t('Sanitize and merge duplicate system tags available'); + } + + #[\Override] + public function run(): SetupResult { + if ($this->repairSanitizeSystemTags->migrationsAvailable()) { + return SetupResult::warning( + $this->l10n->t('One or more system tags need to be sanitized or merged. This can take a long time on larger instances so this is not done automatically during upgrades. Use the command `occ maintenance:repair --include-expensive` to perform the migrations.'), + ); + } else { + return SetupResult::success('None'); + } + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 0db36117e82df..e316ae06d853f 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -2131,6 +2131,7 @@ 'OC\\Repair\\RepairInvalidShares' => $baseDir . '/lib/private/Repair/RepairInvalidShares.php', 'OC\\Repair\\RepairLogoDimension' => $baseDir . '/lib/private/Repair/RepairLogoDimension.php', 'OC\\Repair\\RepairMimeTypes' => $baseDir . '/lib/private/Repair/RepairMimeTypes.php', + 'OC\\Repair\\RepairSanitizeSystemTags' => $baseDir . '/lib/private/Repair/RepairSanitizeSystemTags.php', 'OC\\RichObjectStrings\\RichTextFormatter' => $baseDir . '/lib/private/RichObjectStrings/RichTextFormatter.php', 'OC\\RichObjectStrings\\Validator' => $baseDir . '/lib/private/RichObjectStrings/Validator.php', 'OC\\Route\\CachingRouter' => $baseDir . '/lib/private/Route/CachingRouter.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index b0d0907ac8bd2..8409a3b9c8204 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -2172,6 +2172,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Repair\\RepairInvalidShares' => __DIR__ . '/../../..' . '/lib/private/Repair/RepairInvalidShares.php', 'OC\\Repair\\RepairLogoDimension' => __DIR__ . '/../../..' . '/lib/private/Repair/RepairLogoDimension.php', 'OC\\Repair\\RepairMimeTypes' => __DIR__ . '/../../..' . '/lib/private/Repair/RepairMimeTypes.php', + 'OC\\Repair\\RepairSanitizeSystemTags' => __DIR__ . '/../../..' . '/lib/private/Repair/RepairSanitizeSystemTags.php', 'OC\\RichObjectStrings\\RichTextFormatter' => __DIR__ . '/../../..' . '/lib/private/RichObjectStrings/RichTextFormatter.php', 'OC\\RichObjectStrings\\Validator' => __DIR__ . '/../../..' . '/lib/private/RichObjectStrings/Validator.php', 'OC\\Route\\CachingRouter' => __DIR__ . '/../../..' . '/lib/private/Route/CachingRouter.php', diff --git a/lib/private/Repair.php b/lib/private/Repair.php index b323997f507a0..0f94de519155c 100644 --- a/lib/private/Repair.php +++ b/lib/private/Repair.php @@ -58,6 +58,7 @@ use OC\Repair\RepairInvalidShares; use OC\Repair\RepairLogoDimension; use OC\Repair\RepairMimeTypes; +use OC\Repair\RepairSanitizeSystemTags; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IDBConnection; @@ -204,6 +205,7 @@ public static function getRepairSteps(bool $includeExpensive = false): array { Server::get(OldGroupMembershipShares::class), Server::get(RemoveBrokenProperties::class), Server::get(RepairMimeTypes::class), + Server::get(RepairSanitizeSystemTags::class), ]; $repairSteps = array_merge($repairSteps, $expensiveSteps); } diff --git a/lib/private/Repair/RepairSanitizeSystemTags.php b/lib/private/Repair/RepairSanitizeSystemTags.php new file mode 100644 index 0000000000000..f8b8a8568185c --- /dev/null +++ b/lib/private/Repair/RepairSanitizeSystemTags.php @@ -0,0 +1,261 @@ +connection->getQueryBuilder(); + $qb->select('name') + ->from('systemtag'); + + $result = $qb->executeQuery(); + $available = false; + while (($name = $result->fetchOne()) !== false) { + if ($name !== Util::sanitizeWordsAndEmojis($name)) { + $available = true; + break; + } + } + $result->closeCursor(); + + return $available; + } + + #[\Override] + public function run(IOutput $output): void { + $output->info('Starting sanitization of system tags...'); + + // This is a manually triggered expensive repair step, so we load all + // tags in memory: we need the full set to group duplicates by their + // sanitized name anyway. Each tag already carries its object count. + $tags = $this->getAllTags(); + + // Group tags by sanitized name + $sanitizedMap = []; + foreach ($tags as $tag) { + $sanitizedMap[$tag['sanitizedName']][] = $tag; + } + + $output->info(count($tags) . ' tags found with ' . count($sanitizedMap) . ' unique sanitized names.'); + + // Process each sanitized name group + foreach ($sanitizedMap as $sanitizedName => $group) { + // Single tag, no duplicates found + if (count($group) === 1) { + $tag = $group[0]; + if ($tag['originalName'] !== $sanitizedName) { + $qb = $this->connection->getQueryBuilder(); + $qb->update('systemtag') + ->set('name', $qb->createNamedParameter($sanitizedName)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($tag['id']))) + ->executeStatement(); + $output->info("Sanitized tag ID {$tag['id']}: '{$tag['originalName']}' → '$sanitizedName'"); + } + continue; + } + + // Multiple tags with same sanitized name - merge them + $this->mergeTagGroup($group, $sanitizedName, $output); + } + + $output->info('System tag sanitization and merge completed.'); + } + + private function mergeTagGroup(array $group, string $sanitizedName, IOutput $output): void { + // Validate that all tags in the group have the same visibility and editable settings + $firstTag = $group[0]; + $visibility = $firstTag['visibility']; + $editable = $firstTag['editable']; + + foreach ($group as $tag) { + if ($tag['visibility'] !== $visibility || $tag['editable'] !== $editable) { + $output->warning( + "Cannot merge tag group '$sanitizedName': tags have different visibility or editable settings. " + . 'Manual verification required. Tag IDs: ' . implode(', ', array_column($group, 'id')) + ); + return; + } + } + + // Determine which tag to keep (most object mappings, then lowest ID as tiebreaker) + $keepTag = null; + $maxCount = -1; + + foreach ($group as $tag) { + $count = $tag['objectCount']; + if ($count > $maxCount || ($count === $maxCount && ($keepTag === null || $tag['id'] < $keepTag['id']))) { + $maxCount = $count; + $keepTag = $tag; + } + } + + $keepId = $keepTag['id']; + if ($keepTag === null) { + $output->warning("Cannot merge tag group '$sanitizedName': unable to determine which tag to keep"); + return; + } + + $duplicateIds = array_filter(array_column($group, 'id'), fn ($id) => $id !== $keepId); + if (empty($duplicateIds)) { + return; + } + + $this->connection->beginTransaction(); + try { + // Step 1: Delete ALL mappings from duplicate tags that conflict with keepId + // This must happen FIRST before any updates to avoid unique constraint violations + $this->deleteConflictingMappings($duplicateIds, $keepId); + + // Step 2: Update all remaining mappings from duplicates to keepId + // These won't conflict because we just deleted the conflicts + $qb = $this->connection->getQueryBuilder(); + $qb->update('systemtag_object_mapping') + ->set('systemtagid', $qb->createNamedParameter($keepId)) + ->where($qb->expr()->in('systemtagid', $qb->createNamedParameter($duplicateIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + + // Step 3: Delete duplicate tags in bulk (safe now that mappings are gone) + $qb = $this->connection->getQueryBuilder(); + $qb->delete('systemtag') + ->where($qb->expr()->in('id', $qb->createNamedParameter($duplicateIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + + // Step 4: Sanitize the kept tag name if needed + // This is safe because we've already deleted all duplicates with the same sanitized name + if ($keepTag['originalName'] !== $sanitizedName) { + $qb = $this->connection->getQueryBuilder(); + $qb->update('systemtag') + ->set('name', $qb->createNamedParameter($sanitizedName)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($keepId))) + ->executeStatement(); + } + + $this->connection->commit(); + } catch (\Exception $e) { + $this->connection->rollBack(); + $output->warning("Failed to merge tag group '$sanitizedName': " . $e->getMessage()); + return; + } + + $duplicateIdsList = implode(', ', $duplicateIds); + $output->info("Merged tags [$duplicateIdsList] into ID $keepId (sanitized: '$sanitizedName')"); + } + + /** + * Delete mappings from duplicate tags where the same object is already mapped to keepId + * This prevents unique constraint violations when updating systemtagid + */ + private function deleteConflictingMappings(array $duplicateIds, int $keepId): void { + $batchSize = 1000; + $batch = []; + + // Stream keepId mappings and process in batches + $qb = $this->connection->getQueryBuilder(); + $qb->select('objectid', 'objecttype') + ->from('systemtag_object_mapping') + ->where($qb->expr()->eq('systemtagid', $qb->createNamedParameter($keepId))); + + $result = $qb->executeQuery(); + + while ($mapping = $result->fetch()) { + $batch[] = $mapping; + + // When batch is full, delete conflicts for this batch + if (count($batch) >= $batchSize) { + $this->deleteBatchConflicts($batch, $duplicateIds); + $batch = []; // Clear batch + } + } + + $result->closeCursor(); + + // Process remaining mappings in the last batch + if (!empty($batch)) { + $this->deleteBatchConflicts($batch, $duplicateIds); + } + } + + /** + * Delete mappings in a batch that conflict with keepId mappings + */ + private function deleteBatchConflicts(array $batch, array $duplicateIds): void { + $qb = $this->connection->getQueryBuilder(); + $qb->delete('systemtag_object_mapping') + ->where($qb->expr()->in('systemtagid', $qb->createNamedParameter($duplicateIds, IQueryBuilder::PARAM_INT_ARRAY))); + + $orX = $qb->expr()->orX(); + foreach ($batch as $mapping) { + $orX->add($qb->expr()->andX( + $qb->expr()->eq('objectid', $qb->createNamedParameter($mapping['objectid'])), + $qb->expr()->eq('objecttype', $qb->createNamedParameter($mapping['objecttype'])) + )); + } + $qb->andWhere($orX); + $qb->executeStatement(); + } + + /** + * Fetch all tags together with their object mapping count in a single query. + * + * @return list + */ + private function getAllTags(): array { + $qb = $this->connection->getQueryBuilder(); + $qb->select('t.id', 't.name', 't.visibility', 't.editable') + ->selectAlias($qb->func()->count('m.systemtagid'), 'object_count') + ->from('systemtag', 't') + ->leftJoin('t', 'systemtag_object_mapping', 'm', $qb->expr()->eq('t.id', 'm.systemtagid')) + ->groupBy('t.id', 't.name', 't.visibility', 't.editable') + ->orderBy('t.name') + ->addOrderBy('t.id'); + + $tags = []; + $result = $qb->executeQuery(); + while ($row = $result->fetch()) { + $tags[] = [ + 'id' => (int)$row['id'], + 'originalName' => $row['name'], + 'sanitizedName' => Util::sanitizeWordsAndEmojis($row['name']), + 'visibility' => (int)$row['visibility'], + 'editable' => (int)$row['editable'], + 'objectCount' => (int)$row['object_count'], + ]; + } + $result->closeCursor(); + return $tags; + } +} diff --git a/lib/private/SystemTag/SystemTagManager.php b/lib/private/SystemTag/SystemTagManager.php index 07923438f03ea..eb708ec3cb339 100644 --- a/lib/private/SystemTag/SystemTagManager.php +++ b/lib/private/SystemTag/SystemTagManager.php @@ -27,6 +27,7 @@ use OCP\SystemTag\TagCreationForbiddenException; use OCP\SystemTag\TagNotFoundException; use OCP\SystemTag\TagUpdateForbiddenException; +use OCP\Util; /** * Manager class for system tags @@ -173,6 +174,8 @@ public function createTag(string $tagName, bool $userVisible, bool $userAssignab throw new TagCreationForbiddenException(); } + $tagName = Util::sanitizeWordsAndEmojis($tagName); + // Check if tag already exists (case-insensitive) $existingTags = $this->getAllTags(null, $tagName); foreach ($existingTags as $existingTag) { @@ -245,8 +248,9 @@ public function updateTag( } $beforeUpdate = array_shift($tags); + $newName = Util::sanitizeWordsAndEmojis($newName); + // Length of name column is 64 - $newName = trim($newName); $truncatedNewName = substr($newName, 0, 64); $afterUpdate = new SystemTag( $tagId, @@ -480,5 +484,4 @@ public function getTagGroups(ISystemTag $tag): array { return $groupIds; } - } diff --git a/lib/public/Util.php b/lib/public/Util.php index e03712d2a8297..0a60282412079 100644 --- a/lib/public/Util.php +++ b/lib/public/Util.php @@ -628,4 +628,24 @@ public static function isFunctionEnabled(string $functionName): bool { } return true; } + + /** + * Sanitize a name by removing unwanted characters + * + * This function removes any character that is not a letter, space, or symbol (including emojis). + * It also normalizes spaces by replacing multiple consecutive spaces with a single space and trimming + * leading and trailing spaces. + * + * @param string $input The input string to sanitize + * @return string The sanitized string + * @since 35.0.0 + */ + public static function sanitizeWordsAndEmojis(string $input): string { + // Remove control characters and other invisible separators, but keep everything else. + // preg_replace returns null on a PCRE error (e.g. invalid UTF-8): keep the input untouched in that case. + $clean = preg_replace('/[\p{C}]+/u', '', $input) ?? $input; + + // Normalize whitespace to single spaces + return preg_replace('/[[:space:]]+/u', ' ', trim($clean)) ?? $clean; + } } diff --git a/tests/lib/Repair/RepairSanitizeSystemTagsTest.php b/tests/lib/Repair/RepairSanitizeSystemTagsTest.php new file mode 100644 index 0000000000000..d42e07b166526 --- /dev/null +++ b/tests/lib/Repair/RepairSanitizeSystemTagsTest.php @@ -0,0 +1,211 @@ +connection = Server::get(IDBConnection::class); + $this->output = $this->createMock(IOutput::class); + $this->repair = new RepairSanitizeSystemTags($this->connection); + + $this->cleanUpTables(); + } + + #[\Override] + protected function tearDown(): void { + $this->cleanUpTables(); + parent::tearDown(); + } + + private function cleanUpTables(): void { + $this->connection->getQueryBuilder()->delete('systemtag_object_mapping')->executeStatement(); + $this->connection->getQueryBuilder()->delete('systemtag')->executeStatement(); + } + + public function testMigrationsAvailableFalseWhenAllClean(): void { + $this->addTag('Clean'); + $this->addTag('Also clean'); + + $this->assertFalse($this->repair->migrationsAvailable()); + } + + public function testMigrationsAvailableTrueWhenNameNeedsSanitizing(): void { + $this->addTag('Clean'); + $this->addTag(' Needs trimming '); + + $this->assertTrue($this->repair->migrationsAvailable()); + } + + public function testMigrationsAvailableIgnoresFalsyName(): void { + // A PHP-falsy name ('0') must not stop the scan before later dirty tags are + // seen. We avoid '' here because Oracle stores it as NULL, which the notnull + // name column rejects. + $this->addTag('0'); + $this->addTag('Needs trimming'); + + $this->assertTrue($this->repair->migrationsAvailable()); + } + + public function testRunSanitizesSingleTag(): void { + // Leading/trailing whitespace is trimmed and inner runs collapse to a single space + $id = $this->addTag(' My Tag '); + + $this->repair->run($this->output); + + $this->assertSame('My Tag', $this->getTagName($id)); + $this->assertFalse($this->repair->migrationsAvailable()); + } + + public function testRunMergesDuplicatesAndReassignsMappings(): void { + // Both sanitize to 'Report card', same visibility/editable, so they must merge. + // The variants differ by an inner double space, which is significant in the DB + // unique index (unlike trailing spaces, which MySQL/Oracle ignore). + $keep = $this->addTag('Report card'); + $dup = $this->addTag('Report card'); + + // The kept tag is the one with the most object mappings + $this->addMapping($keep, '1'); + $this->addMapping($keep, '2'); + $this->addMapping($dup, '3'); + + $this->repair->run($this->output); + + // Only one tag remains, named 'Report card' + $this->assertSame(1, $this->countTags()); + $this->assertSame('Report card', $this->getTagName($keep)); + $this->assertNull($this->getTagName($dup)); + + // The duplicate's mapping was reassigned to the kept tag + $this->assertSame(3, $this->countMappings($keep)); + } + + public function testRunKeepsTagWithMostMappings(): void { + $few = $this->addTag('Photo album'); + $many = $this->addTag('Photo album'); + + $this->addMapping($few, '1'); + $this->addMapping($many, '2'); + $this->addMapping($many, '3'); + + $this->repair->run($this->output); + + $this->assertNull($this->getTagName($few)); + $this->assertSame('Photo album', $this->getTagName($many)); + $this->assertSame(3, $this->countMappings($many)); + } + + public function testRunDeletesConflictingMappings(): void { + $a = $this->addTag('Draft doc'); + $b = $this->addTag('Draft doc'); + + // Object '1' is mapped to both tags. After the merge that conflict must be + // deduplicated: the surviving tag ends up with objects '1' and '2' only once each. + $this->addMapping($a, '1'); + $this->addMapping($b, '1'); + $this->addMapping($b, '2'); + + $this->repair->run($this->output); + + // A single tag remains and holds exactly two mappings (no duplicated object '1') + $this->assertSame(1, $this->countTags()); + $this->assertSame(2, $this->countAllMappings()); + } + + public function testRunSkipsMergeWhenVisibilityDiffers(): void { + // Same sanitized name but different visibility → must not merge, only warn + $a = $this->addTag('Shared note', visibility: 1); + $b = $this->addTag('Shared note', visibility: 0); + + $this->output->expects($this->atLeastOnce())->method('warning'); + + $this->repair->run($this->output); + + $this->assertSame(2, $this->countTags()); + $this->assertNotNull($this->getTagName($a)); + $this->assertNotNull($this->getTagName($b)); + } + + private function addTag(string $name, int $visibility = 1, int $editable = 1): int { + $qb = $this->connection->getQueryBuilder(); + $qb->insert('systemtag') + ->values([ + 'name' => $qb->createNamedParameter($name), + 'visibility' => $qb->createNamedParameter($visibility, IQueryBuilder::PARAM_INT), + 'editable' => $qb->createNamedParameter($editable, IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + + return $qb->getLastInsertId(); + } + + private function addMapping(int $tagId, string $objectId, string $objectType = 'files'): void { + $qb = $this->connection->getQueryBuilder(); + $qb->insert('systemtag_object_mapping') + ->values([ + 'objectid' => $qb->createNamedParameter($objectId), + 'objecttype' => $qb->createNamedParameter($objectType), + 'systemtagid' => $qb->createNamedParameter($tagId, IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + } + + private function getTagName(int $id): ?string { + $qb = $this->connection->getQueryBuilder(); + $name = $qb->select('name') + ->from('systemtag') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))) + ->executeQuery() + ->fetchOne(); + + return $name === false ? null : $name; + } + + private function countTags(): int { + $qb = $this->connection->getQueryBuilder(); + return (int)$qb->select($qb->func()->count('*')) + ->from('systemtag') + ->executeQuery() + ->fetchOne(); + } + + private function countMappings(int $tagId): int { + $qb = $this->connection->getQueryBuilder(); + return (int)$qb->select($qb->func()->count('*')) + ->from('systemtag_object_mapping') + ->where($qb->expr()->eq('systemtagid', $qb->createNamedParameter($tagId, IQueryBuilder::PARAM_INT))) + ->executeQuery() + ->fetchOne(); + } + + private function countAllMappings(): int { + $qb = $this->connection->getQueryBuilder(); + return (int)$qb->select($qb->func()->count('*')) + ->from('systemtag_object_mapping') + ->executeQuery() + ->fetchOne(); + } +} diff --git a/tests/lib/UtilTest.php b/tests/lib/UtilTest.php index 182796ea720fc..aba50c774ab32 100644 --- a/tests/lib/UtilTest.php +++ b/tests/lib/UtilTest.php @@ -397,4 +397,54 @@ public function testMb_array_change_key_case(): void { $expected = $arrayResult; $this->assertEquals($result, $expected); } + + public static function sanitizeProvider(): array { + return [ + // Basic spaces and line controls + ['Hello World', 'Hello World'], + [' Hello World ', 'Hello World'], + ["Hello\t World \nAgain", 'Hello World Again'], + ["Hello\rWorld", 'HelloWorld'], + ["Hello\r\nWorld", 'HelloWorld'], + ["Hello\u{200B}World", 'HelloWorld'], // zero-width space removed + ["Hello\t\n\r World", 'Hello World'], + + // Unicode, emoji, and CJK + ['テスト 😃 💬', 'テスト 😃 💬'], + ['中文測試 ✅', '中文測試 ✅'], + ['Русский текст 😁', 'Русский текст 😁'], + ['Café crème ☕', 'Café crème ☕'], + + // Punctuation and filename-like + ['Hello-World_123.', 'Hello-World_123.'], + ['File.name, with commas', 'File.name, with commas'], + ['Smile — dash', 'Smile — dash'], + ['Invalid:/\\?%*|<>name', 'Invalid:/\\?%*|<>name'], // kept as is + ['test@example.com', 'test@example.com'], + + // Control and invisible chars + ["Bad\0Name", 'BadName'], + ["Hello\u{0007}World", 'HelloWorld'], + ["Line\r\nbreaks", 'Linebreaks'], + ["\x1F Hidden control", 'Hidden control'], + + // Whitespace and normalization + [" Multiple spaces\t and \nnewlines ", 'Multiple spaces and newlines'], + ["No-break\u{00A0}space", 'No-break space'], // NBSP normalized + ["Zero\u{2003}width\u{2009}spaces", 'Zero width spaces'], // various spaces + + // Complex mixes + ['テスト 💬.png', 'テスト 💬.png'], + [' Mix 😎 emojis 🎉 and 123 numbers ', 'Mix 😎 emojis 🎉 and 123 numbers'], + ["Hello \u{200B}\n World", 'Hello World'], + ['Path ../etc/passwd', 'Path ../etc/passwd'], + ['Symbols! @ # % ^ & * ( )', 'Symbols! @ # % ^ & * ( )'], + ['Special chars