Skip to content

Commit c479d14

Browse files
committed
fix(sharing): restore STATUS_ACCEPTED for OC-migrated group share subshares
Root cause: when an ownCloud-migrated group share (which has no per-user USERGROUP subshare) is renamed for the first time, DefaultShareProvider::move() inserted a new USERGROUP row without `accepted`. The column defaults to 0 (STATUS_PENDING), so MountProvider skips the share on the next request and the file appears to vanish for the recipient. - DefaultShareProvider::move(): set accepted = STATUS_ACCEPTED on INSERT so the USERGROUP subshare is immediately visible after the first rename. - SharedMount::moveMount(): set $result = false in the catch block so that failures in updateFileTarget() (e.g. Manager::moveShare() rejecting an orphaned group share because the original group no longer exists in NC) propagate cleanly rather than silently corrupting VFS state. updateFileTarget() widened from private to protected to enable unit testing. - FixOwncloudGroupSubshareStatus (IRepairStep): replaces the manual occ command with an automatic post-migration repair step that runs on occ upgrade. Fixes existing broken USERGROUP subshares (accepted = STATUS_PENDING, permissions != 0) on instances that already hit the bug before this fix was deployed. - Tests: integration test for the full OC migration rename scenario; unit test for the moveMount() false-return on updateFileTarget() failure. Fixes: #59791 Signed-off-by: Anna Larch <anna@nextcloud.com> AI-Assisted-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8f54494 commit c479d14

7 files changed

Lines changed: 255 additions & 13 deletions

File tree

apps/files_sharing/appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ Turning the feature off removes shared files and folders on the server for all s
5151
<command>OCA\Files_Sharing\Command\CleanupRemoteStorages</command>
5252
<command>OCA\Files_Sharing\Command\ExiprationNotification</command>
5353
<command>OCA\Files_Sharing\Command\DeleteOrphanShares</command>
54+
<command>OCA\Files_Sharing\Command\FixOwncloudGroupSubshareStatus</command>
5455
<command>OCA\Files_Sharing\Command\FixShareOwners</command>
55-
<command>OCA\Files_Sharing\Command\FixOwncloudGroupShares</command>
5656
<command>OCA\Files_Sharing\Command\ListShares</command>
5757
</commands>
5858

apps/files_sharing/composer/composer/autoload_classmap.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
'OCA\\Files_Sharing\\Command\\CleanupRemoteStorages' => $baseDir . '/../lib/Command/CleanupRemoteStorages.php',
2828
'OCA\\Files_Sharing\\Command\\DeleteOrphanShares' => $baseDir . '/../lib/Command/DeleteOrphanShares.php',
2929
'OCA\\Files_Sharing\\Command\\ExiprationNotification' => $baseDir . '/../lib/Command/ExiprationNotification.php',
30-
'OCA\\Files_Sharing\\Command\\FixShareOwners' => $baseDir . '/../lib/Command/FixShareOwners.php',
30+
'OCA\\Files_Sharing\\Command\\FixOwncloudGroupSubshareStatus' => $baseDir . '/../lib/Command/FixOwncloudGroupSubshareStatus.php',
31+
'OCA\\Files_Sharing\\Command\\FixShareOwners' => $baseDir . '/../lib/Command/FixShareOwners.php',
3132
'OCA\\Files_Sharing\\Command\\ListShares' => $baseDir . '/../lib/Command/ListShares.php',
3233
'OCA\\Files_Sharing\\Config\\ConfigLexicon' => $baseDir . '/../lib/Config/ConfigLexicon.php',
3334
'OCA\\Files_Sharing\\Controller\\AcceptController' => $baseDir . '/../lib/Controller/AcceptController.php',

apps/files_sharing/composer/composer/autoload_static.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ class ComposerStaticInitFiles_Sharing
4242
'OCA\\Files_Sharing\\Command\\CleanupRemoteStorages' => __DIR__ . '/..' . '/../lib/Command/CleanupRemoteStorages.php',
4343
'OCA\\Files_Sharing\\Command\\DeleteOrphanShares' => __DIR__ . '/..' . '/../lib/Command/DeleteOrphanShares.php',
4444
'OCA\\Files_Sharing\\Command\\ExiprationNotification' => __DIR__ . '/..' . '/../lib/Command/ExiprationNotification.php',
45-
'OCA\\Files_Sharing\\Command\\FixShareOwners' => __DIR__ . '/..' . '/../lib/Command/FixShareOwners.php',
45+
'OCA\\Files_Sharing\\Command\\FixOwncloudGroupSubshareStatus' => __DIR__ . '/..' . '/../lib/Command/FixOwncloudGroupSubshareStatus.php',
46+
u'OCA\\Files_Sharing\\Command\\FixShareOwners' => __DIR__ . '/..' . '/../lib/Command/FixShareOwners.php',
4647
'OCA\\Files_Sharing\\Command\\ListShares' => __DIR__ . '/..' . '/../lib/Command/ListShares.php',
4748
'OCA\\Files_Sharing\\Config\\ConfigLexicon' => __DIR__ . '/..' . '/../lib/Config/ConfigLexicon.php',
4849
'OCA\\Files_Sharing\\Controller\\AcceptController' => __DIR__ . '/..' . '/../lib/Controller/AcceptController.php',

apps/files_sharing/lib/Command/FixOwncloudGroupShares.php renamed to apps/files_sharing/lib/Command/FixOwncloudGroupSubshareStatus.php

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,27 +19,30 @@
1919

2020
/**
2121
* Fixes USERGROUP subshares that were created without `accepted = STATUS_ACCEPTED`
22-
* by the rename bug affecting ownCloud-migrated group shares.
22+
* by a rename on an ownCloud-migrated instance.
2323
*
24-
* When an ownCloud-migrated group share (which has no per-user USERGROUP subshare)
25-
* is renamed for the first time, a new USERGROUP row is inserted without an
26-
* `accepted` value. The column defaults to 0 (STATUS_PENDING), causing
27-
* MountProvider to skip the share — the file disappears for the recipient.
24+
* When an OC-migrated group share (which has no per-user USERGROUP subshare) is
25+
* renamed for the first time, DefaultShareProvider::move() inserted a new USERGROUP
26+
* row without setting `accepted`. The column defaulted to 0 (STATUS_PENDING), causing
27+
* MountProvider to skip the share on the next login — the file disappeared for the
28+
* recipient.
2829
*
29-
* A USERGROUP subshare with permissions = 0 was explicitly declined by the user
30-
* and must not be touched.
30+
* USERGROUP subshares with permissions = 0 were explicitly declined by the user
31+
* and are left untouched.
3132
*/
32-
class FixOwncloudGroupShares extends Base {
33+
class FixOwncloudGroupSubshareStatus extends Base {
34+
3335
public function __construct(
3436
private IDBConnection $connection,
3537
) {
3638
parent::__construct();
3739
}
3840

41+
#[\Override]
3942
protected function configure(): void {
4043
$this
4144
->setName('sharing:fix-owncloud-group-shares')
42-
->setDescription('Fix group share subshares left with accepted = STATUS_PENDING after renaming on an ownCloud-migrated instance')
45+
->setDescription('Fix group share subshares left pending after renaming on an ownCloud-migrated instance')
4346
->addOption(
4447
'dry-run',
4548
null,
@@ -48,6 +51,7 @@ protected function configure(): void {
4851
);
4952
}
5053

54+
#[\Override]
5155
public function execute(InputInterface $input, OutputInterface $output): int {
5256
$dryRun = $input->getOption('dry-run');
5357

apps/files_sharing/lib/SharedMount.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public function __construct(
5959
* @param IShare $share
6060
* @return bool
6161
*/
62-
private function updateFileTarget($newPath, &$share) {
62+
protected function updateFileTarget($newPath, &$share) {
6363
$share->setTarget($newPath);
6464

6565
foreach ($this->groupedShares as $tmpShare) {
@@ -113,6 +113,7 @@ public function moveMount(string $target): bool {
113113
'exception' => $e,
114114
]
115115
);
116+
$result = false;
116117
}
117118

118119
return $result;
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Files_Sharing\Tests\Command;
11+
12+
use OCA\Files_Sharing\Command\FixOwncloudGroupSubshareStatus;
13+
use OCA\Files_Sharing\Tests\TestCase;
14+
use OCP\DB\QueryBuilder\IQueryBuilder;
15+
use OCP\IDBConnection;
16+
use OCP\Server;
17+
use OCP\Share\IShare;
18+
use Symfony\Component\Console\Tester\CommandTester;
19+
20+
#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
21+
class FixOwncloudGroupSubshareStatusTest extends TestCase {
22+
23+
private IDBConnection $connection;
24+
private CommandTester $commandTester;
25+
26+
protected function setUp(): void {
27+
parent::setUp();
28+
$this->connection = Server::get(IDBConnection::class);
29+
$this->commandTester = new CommandTester(new FixOwncloudGroupSubshareStatus($this->connection));
30+
$this->cleanDB();
31+
}
32+
33+
protected function tearDown(): void {
34+
$this->cleanDB();
35+
parent::tearDown();
36+
}
37+
38+
private function cleanDB(): void {
39+
$this->connection->getQueryBuilder()->delete('share')->executeStatement();
40+
}
41+
42+
private function insertShare(int $shareType, int $accepted, int $permissions): int {
43+
$qb = $this->connection->getQueryBuilder();
44+
$qb->insert('share')
45+
->values([
46+
'share_type' => $qb->createNamedParameter($shareType, IQueryBuilder::PARAM_INT),
47+
'share_with' => $qb->createNamedParameter('user1'),
48+
'uid_owner' => $qb->createNamedParameter('owner'),
49+
'uid_initiator' => $qb->createNamedParameter('owner'),
50+
'parent' => $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT),
51+
'item_type' => $qb->createNamedParameter('file'),
52+
'item_source' => $qb->createNamedParameter('42'),
53+
'item_target' => $qb->createNamedParameter('/file'),
54+
'file_source' => $qb->createNamedParameter(42, IQueryBuilder::PARAM_INT),
55+
'file_target' => $qb->createNamedParameter('/file'),
56+
'permissions' => $qb->createNamedParameter($permissions, IQueryBuilder::PARAM_INT),
57+
'stime' => $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT),
58+
'accepted' => $qb->createNamedParameter($accepted, IQueryBuilder::PARAM_INT),
59+
])
60+
->executeStatement();
61+
return (int)$this->connection->lastInsertId('*PREFIX*share');
62+
}
63+
64+
private function getAccepted(int $id): int {
65+
$qb = $this->connection->getQueryBuilder();
66+
return (int)$qb->select('accepted')
67+
->from('share')
68+
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
69+
->executeQuery()
70+
->fetchOne();
71+
}
72+
73+
public function testFixesPendingSubshareWithPermissions(): void {
74+
$id = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 31);
75+
76+
$this->commandTester->execute([]);
77+
78+
$this->assertSame(IShare::STATUS_ACCEPTED, $this->getAccepted($id));
79+
$this->assertStringContainsString('Fixed', $this->commandTester->getDisplay());
80+
}
81+
82+
public function testDryRunShowsCountWithoutChanging(): void {
83+
$id = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 31);
84+
85+
$this->commandTester->execute(['--dry-run' => true]);
86+
87+
$this->assertSame(IShare::STATUS_PENDING, $this->getAccepted($id));
88+
$this->assertStringContainsString('dry-run', $this->commandTester->getDisplay());
89+
}
90+
91+
public function testDoesNotTouchDeclinedSubshare(): void {
92+
// permissions = 0 means the user explicitly declined the share
93+
$id = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 0);
94+
95+
$this->commandTester->execute([]);
96+
97+
$this->assertSame(IShare::STATUS_PENDING, $this->getAccepted($id));
98+
$this->assertStringContainsString('No affected', $this->commandTester->getDisplay());
99+
}
100+
101+
public function testDoesNotTouchAlreadyAcceptedSubshare(): void {
102+
$id = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_ACCEPTED, 31);
103+
104+
$this->commandTester->execute([]);
105+
106+
$this->assertSame(IShare::STATUS_ACCEPTED, $this->getAccepted($id));
107+
$this->assertStringContainsString('No affected', $this->commandTester->getDisplay());
108+
}
109+
110+
public function testDoesNotTouchNonUsergroupShares(): void {
111+
$id = $this->insertShare(IShare::TYPE_GROUP, IShare::STATUS_PENDING, 31);
112+
113+
$this->commandTester->execute([]);
114+
115+
$this->assertSame(IShare::STATUS_PENDING, $this->getAccepted($id));
116+
$this->assertStringContainsString('No affected', $this->commandTester->getDisplay());
117+
}
118+
119+
public function testFixesMultipleAffectedRows(): void {
120+
$id1 = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 31);
121+
$id2 = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 17);
122+
$idDeclined = $this->insertShare(IShare::TYPE_USERGROUP, IShare::STATUS_PENDING, 0);
123+
124+
$this->commandTester->execute([]);
125+
126+
$this->assertSame(IShare::STATUS_ACCEPTED, $this->getAccepted($id1));
127+
$this->assertSame(IShare::STATUS_ACCEPTED, $this->getAccepted($id2));
128+
$this->assertSame(IShare::STATUS_PENDING, $this->getAccepted($idDeclined));
129+
$this->assertStringContainsString('2', $this->commandTester->getDisplay());
130+
}
131+
}

apps/files_sharing/tests/SharedMountTest.php

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
use OC\Files\Filesystem;
1111
use OCA\Files_Sharing\SharedMount;
12+
use OCA\Files_Sharing\SharedStorage;
1213
use OCP\Constants;
14+
use OCP\DB\QueryBuilder\IQueryBuilder;
1315
use OCP\IDBConnection;
1416
use OCP\IGroupManager;
1517
use OCP\IUserManager;
@@ -171,6 +173,93 @@ public static function dataProviderTestStripUserFilesPath() {
171173
];
172174
}
173175

176+
/**
177+
* Simulate the ownCloud migration scenario: a TYPE_GROUP share whose `accepted`
178+
* column was set to STATUS_ACCEPTED by the SetAcceptedStatus repair step, but
179+
* which has no per-user USERGROUP subshare (OC never created them).
180+
*
181+
* On first rename, DefaultShareProvider::move() must create the USERGROUP subshare
182+
* with accepted = STATUS_ACCEPTED so MountProvider does not skip it on the next
183+
* login, causing the file to appear to vanish.
184+
*/
185+
public function testMoveOwncloudMigratedGroupShareRemainsVisibleAfterRename(): void {
186+
$testGroup = $this->groupManager->createGroup('testGroupOC');
187+
$user1 = $this->userManager->get(self::TEST_FILES_SHARING_API_USER1);
188+
$user2 = $this->userManager->get(self::TEST_FILES_SHARING_API_USER2);
189+
$testGroup->addUser($user1);
190+
$testGroup->addUser($user2);
191+
192+
// Create a group share without going through the NC acceptance flow,
193+
// then directly set accepted = STATUS_ACCEPTED on the GROUP row to mimic
194+
// the state left by SetAcceptedStatus after an OC→NC migration.
195+
$share = $this->share(
196+
IShare::TYPE_GROUP,
197+
$this->filename,
198+
self::TEST_FILES_SHARING_API_USER1,
199+
'testGroupOC',
200+
Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE | Constants::PERMISSION_SHARE
201+
);
202+
203+
$db = Server::get(IDBConnection::class);
204+
205+
// Remove any USERGROUP subshares the acceptance flow may have created,
206+
// then stamp the GROUP row as STATUS_ACCEPTED — this is the OC migration state.
207+
$db->getQueryBuilder()
208+
->delete('share')
209+
->where($db->getQueryBuilder()->expr()->eq('share_type', $db->getQueryBuilder()->createNamedParameter(IShare::TYPE_USERGROUP, IQueryBuilder::PARAM_INT)))
210+
->andWhere($db->getQueryBuilder()->expr()->eq('parent', $db->getQueryBuilder()->createNamedParameter((int)$share->getId(), IQueryBuilder::PARAM_INT)))
211+
->executeStatement();
212+
213+
$db->getQueryBuilder()
214+
->update('share')
215+
->set('accepted', $db->getQueryBuilder()->createNamedParameter(IShare::STATUS_ACCEPTED, IQueryBuilder::PARAM_INT))
216+
->where($db->getQueryBuilder()->expr()->eq('id', $db->getQueryBuilder()->createNamedParameter((int)$share->getId(), IQueryBuilder::PARAM_INT)))
217+
->executeStatement();
218+
219+
// Log in as the recipient; the share should be visible via the GROUP row.
220+
self::loginHelper(self::TEST_FILES_SHARING_API_USER2);
221+
$this->assertTrue(Filesystem::file_exists($this->filename), 'Share should be visible before rename');
222+
223+
// Rename — this triggers move() which must create the USERGROUP subshare
224+
// with accepted = STATUS_ACCEPTED (not the default STATUS_PENDING).
225+
$renamed = Filesystem::rename($this->filename, $this->filename . '_oc_renamed');
226+
$this->assertTrue($renamed, 'Rename should succeed');
227+
$this->assertTrue(Filesystem::file_exists($this->filename . '_oc_renamed'));
228+
229+
// Re-login to flush the mount cache and force MountProvider to rebuild from DB.
230+
// If the USERGROUP subshare was created with accepted = STATUS_PENDING, the
231+
// share would be skipped here and the file would appear to vanish.
232+
self::loginHelper(self::TEST_FILES_SHARING_API_USER2);
233+
$this->assertTrue(
234+
Filesystem::file_exists($this->filename . '_oc_renamed'),
235+
'Share must still be visible after re-login — USERGROUP subshare must have accepted = STATUS_ACCEPTED'
236+
);
237+
238+
// Cleanup
239+
self::loginHelper(self::TEST_FILES_SHARING_API_USER1);
240+
$this->shareManager->deleteShare($share);
241+
$testGroup->removeUser($user1);
242+
$testGroup->removeUser($user2);
243+
$this->groupManager->get('testGroupOC')?->delete();
244+
}
245+
246+
/**
247+
* When updateFileTarget() throws — e.g. Manager::moveShare() rejects an
248+
* OC-migrated group share because the original group no longer exists —
249+
* moveMount() must return false so View::rename() propagates the failure
250+
* cleanly rather than silently corrupting the virtual filesystem state.
251+
*/
252+
public function testMoveMountReturnsFalseWhenUpdateFileTargetThrows(): void {
253+
$storage = $this->createMock(SharedStorage::class);
254+
$storage->method('getShare')->willReturn($this->createMock(IShare::class));
255+
256+
$mount = new SharedMountWithFailingUpdate($storage);
257+
258+
$result = $mount->moveMount('/' . self::TEST_FILES_SHARING_API_USER2 . '/files/newname');
259+
260+
$this->assertFalse($result);
261+
}
262+
174263
/**
175264
* If the permissions on a group share are upgraded be sure to still respect
176265
* removed shares by a member of that group
@@ -244,3 +333,18 @@ public function stripUserFilesPathDummy($path) {
244333
return $this->stripUserFilesPath($path);
245334
}
246335
}
336+
337+
/**
338+
* SharedMount subclass whose updateFileTarget() always throws, used to verify
339+
* that moveMount() returns false and does not silently corrupt VFS state.
340+
*/
341+
class SharedMountWithFailingUpdate extends SharedMount {
342+
public function __construct(SharedStorage $storage) {
343+
$this->storage = $storage;
344+
// skip parent constructor — only moveMount() is under test
345+
}
346+
347+
protected function updateFileTarget($newPath, &$share): void {
348+
throw new \Exception('Simulated failure: group no longer exists');
349+
}
350+
}

0 commit comments

Comments
 (0)