From 6368192d41d957fbca1090d93730e2ebf15d84a0 Mon Sep 17 00:00:00 2001 From: Madison Steiner <8176115+mh0pe@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:33:32 -0700 Subject: [PATCH 1/2] fix(files): preserve mtimes when copying and moving Signed-off-by: Madison Steiner <8176115+mh0pe@users.noreply.github.com> --- build/psalm-baseline.xml | 1 - lib/private/Files/Storage/Common.php | 6 ++- lib/private/Files/Storage/Local.php | 2 +- .../Files/Storage/Wrapper/KnownMtime.php | 6 ++- lib/private/Files/View.php | 33 ++++++++++++++- tests/lib/Files/Storage/CommonTest.php | 17 ++++++++ tests/lib/Files/Storage/LocalTest.php | 17 ++++++++ .../Files/Storage/Wrapper/KnownMtimeTest.php | 25 +++++++++++ tests/lib/Files/ViewTest.php | 42 +++++++++++++++++++ 9 files changed, 143 insertions(+), 6 deletions(-) diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 7e59e0d212c8e..34b64898070ba 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -3851,7 +3851,6 @@ - copyFromStorage($sourceStorage, $sourceInternalPath . '/' . $file, $targetInternalPath . '/' . $file)]]> diff --git a/lib/private/Files/Storage/Common.php b/lib/private/Files/Storage/Common.php index 35e4a0425913f..520ad993ae44c 100644 --- a/lib/private/Files/Storage/Common.php +++ b/lib/private/Files/Storage/Common.php @@ -552,9 +552,13 @@ public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalP $result = true; while ($result && ($file = readdir($dh)) !== false) { if (!Filesystem::isIgnoredDir($file)) { - $result &= $this->copyFromStorage($sourceStorage, $sourceInternalPath . '/' . $file, $targetInternalPath . '/' . $file); + $result = $this->copyFromStorage($sourceStorage, $sourceInternalPath . '/' . $file, $targetInternalPath . '/' . $file, $preserveMtime); } } + if ($result && $preserveMtime) { + $mtime = $sourceStorage->filemtime($sourceInternalPath); + $this->touch($targetInternalPath, is_int($mtime) ? $mtime : null); + } } } else { $source = $sourceStorage->fopen($sourceInternalPath, 'r'); diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php index e38485f755482..3f226da81731a 100644 --- a/lib/private/Files/Storage/Local.php +++ b/lib/private/Files/Storage/Local.php @@ -592,7 +592,7 @@ public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalP $rootStorage = new Local(['datadir' => '/']); return $rootStorage->copy($sourceStorage->getSourcePath($sourceInternalPath), $this->getSourcePath($targetInternalPath)); } else { - return parent::copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + return parent::copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath, $preserveMtime); } } diff --git a/lib/private/Files/Storage/Wrapper/KnownMtime.php b/lib/private/Files/Storage/Wrapper/KnownMtime.php index 881c2aad1d2f2..1f07a0944df96 100644 --- a/lib/private/Files/Storage/Wrapper/KnownMtime.php +++ b/lib/private/Files/Storage/Wrapper/KnownMtime.php @@ -97,9 +97,10 @@ public function unlink(string $path): bool { #[\Override] public function rename(string $source, string $target): bool { + $mtime = $this->filemtime($source); $result = parent::rename($source, $target); if ($result) { - $this->knowMtimes->set($target, $this->clock->now()->getTimestamp()); + $this->knowMtimes->set($target, is_int($mtime) ? $mtime : $this->clock->now()->getTimestamp()); $this->knowMtimes->set($source, $this->clock->now()->getTimestamp()); } return $result; @@ -143,9 +144,10 @@ public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalP #[\Override] public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + $mtime = $sourceStorage->filemtime($sourceInternalPath); $result = parent::moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); if ($result) { - $this->knowMtimes->set($targetInternalPath, $this->clock->now()->getTimestamp()); + $this->knowMtimes->set($targetInternalPath, is_int($mtime) ? $mtime : $this->clock->now()->getTimestamp()); } return $result; } diff --git a/lib/private/Files/View.php b/lib/private/Files/View.php index c4162c7df046f..6fe7faeed8289 100644 --- a/lib/private/Files/View.php +++ b/lib/private/Files/View.php @@ -304,6 +304,33 @@ protected function copyUpdate(Storage $sourceStorage, Storage $targetStorage, st } } + private function preserveCopyMtime(Storage $sourceStorage, Storage $targetStorage, string $sourceInternalPath, string $targetInternalPath): void { + if ($sourceStorage->is_dir($sourceInternalPath)) { + $directory = $sourceStorage->opendir($sourceInternalPath); + if (is_resource($directory)) { + try { + while (($file = readdir($directory)) !== false) { + if (!Filesystem::isIgnoredDir($file)) { + $this->preserveCopyMtime( + $sourceStorage, + $targetStorage, + $sourceInternalPath . '/' . $file, + $targetInternalPath . '/' . $file, + ); + } + } + } finally { + closedir($directory); + } + } + } + + $mtime = $sourceStorage->filemtime($sourceInternalPath); + if (is_int($mtime)) { + $targetStorage->touch($targetInternalPath, $mtime); + } + } + /** * @param string $path * @return bool|mixed @@ -938,7 +965,7 @@ private function validateMountMove(array $mounts, IMountPoint $sourceMount, IMou * * @return bool|mixed */ - public function copy($source, $target, $preserveMtime = false) { + public function copy($source, $target, $preserveMtime = true) { $absolutePath1 = Filesystem::normalizePath($this->getAbsolutePath($source)); $absolutePath2 = Filesystem::normalizePath($this->getAbsolutePath($target)); $result = false; @@ -995,6 +1022,10 @@ public function copy($source, $target, $preserveMtime = false) { $result = $storage2->copyFromStorage($storage1, $internalPath1, $internalPath2); } + if ($result && $preserveMtime) { + $this->preserveCopyMtime($storage1, $storage2, $internalPath1, $internalPath2); + } + if ($result) { $this->copyUpdate($storage1, $storage2, $internalPath1, $internalPath2); } diff --git a/tests/lib/Files/Storage/CommonTest.php b/tests/lib/Files/Storage/CommonTest.php index 8b202c0f30a50..1275b0e110363 100644 --- a/tests/lib/Files/Storage/CommonTest.php +++ b/tests/lib/Files/Storage/CommonTest.php @@ -138,4 +138,21 @@ public function testMoveFromStorageNestedJail(): void { $instance->moveFromStorage($source, 'foo.txt', 'bar.txt'); $this->assertTrue($instance->file_exists('bar.txt')); } + + public function testMoveFromStoragePreservesMtimeRecursively(): void { + $sourceDir = $this->tmpDir . '/source'; + mkdir($sourceDir); + $source = new \OC\Files\Storage\CommonTest(['datadir' => $sourceDir]); + $source->mkdir('folder'); + $source->file_put_contents('folder/file.txt', 'content'); + + $fileMtime = time() - 3600; + $folderMtime = $fileMtime - 3600; + $source->touch('folder/file.txt', $fileMtime); + $source->touch('folder', $folderMtime); + + $this->assertTrue($this->instance->moveFromStorage($source, 'folder', 'moved')); + $this->assertSame($fileMtime, $this->instance->filemtime('moved/file.txt')); + $this->assertSame($folderMtime, $this->instance->filemtime('moved')); + } } diff --git a/tests/lib/Files/Storage/LocalTest.php b/tests/lib/Files/Storage/LocalTest.php index 5bfc6ed060c42..e94151f9fbd63 100644 --- a/tests/lib/Files/Storage/LocalTest.php +++ b/tests/lib/Files/Storage/LocalTest.php @@ -164,4 +164,21 @@ public function testMoveNestedJail(): void { $jail3->moveFromStorage($jail2, 'file.txt', 'file.txt'); $this->assertTrue($this->instance->file_exists('target/file.txt')); } + + public function testMoveFromNonLocalStoragePreservesMtimeRecursively(): void { + $sourceDir = $this->tmpDir . '/source'; + mkdir($sourceDir); + $source = new \OC\Files\Storage\CommonTest(['datadir' => $sourceDir]); + $source->mkdir('folder'); + $source->file_put_contents('folder/file.txt', 'content'); + + $fileMtime = time() - 3600; + $folderMtime = $fileMtime - 3600; + $source->touch('folder/file.txt', $fileMtime); + $source->touch('folder', $folderMtime); + + $this->assertTrue($this->instance->moveFromStorage($source, 'folder', 'moved')); + $this->assertSame($fileMtime, $this->instance->filemtime('moved/file.txt')); + $this->assertSame($folderMtime, $this->instance->filemtime('moved')); + } } diff --git a/tests/lib/Files/Storage/Wrapper/KnownMtimeTest.php b/tests/lib/Files/Storage/Wrapper/KnownMtimeTest.php index cc88e63072e2f..13d44561b544a 100644 --- a/tests/lib/Files/Storage/Wrapper/KnownMtimeTest.php +++ b/tests/lib/Files/Storage/Wrapper/KnownMtimeTest.php @@ -66,4 +66,29 @@ public function testNewerKnownMtime(): void { $this->assertEquals($future, $this->instance->stat('foo.txt')['mtime']); $this->assertEquals($future, $this->instance->getMetaData('foo.txt')['mtime']); } + + public function testMoveFromStoragePreservesKnownMtime(): void { + $source = new Temporary([]); + try { + $source->file_put_contents('source.txt', 'content'); + $mtime = time() - 3600; + $source->touch('source.txt', $mtime); + $this->fakeTime = time() + 3600; + + $this->assertTrue($this->instance->moveFromStorage($source, 'source.txt', 'target.txt')); + $this->assertSame($mtime, $this->instance->filemtime('target.txt')); + } finally { + $source->cleanUp(); + } + } + + public function testRenamePreservesKnownMtime(): void { + $this->instance->file_put_contents('source.txt', 'content'); + $mtime = time() - 3600; + $this->instance->touch('source.txt', $mtime); + $this->fakeTime = time() + 3600; + + $this->assertTrue($this->instance->rename('source.txt', 'target.txt')); + $this->assertSame($mtime, $this->instance->filemtime('target.txt')); + } } diff --git a/tests/lib/Files/ViewTest.php b/tests/lib/Files/ViewTest.php index c21bf6fc40c9d..2d22e0c5a6835 100644 --- a/tests/lib/Files/ViewTest.php +++ b/tests/lib/Files/ViewTest.php @@ -510,6 +510,48 @@ public function testCopyBetweenStorageCrossNonLocal(): void { $this->copyBetweenStorages($storage1, $storage2); } + public static function copyPreservesMtimeProvider(): array { + return [ + 'same storage' => [false], + 'cross storage' => [true], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('copyPreservesMtimeProvider')] + public function testCopyPreservesMtimeRecursivelyByDefault(bool $crossStorage): void { + $sourceStorage = $this->getTestStorage(true, TemporaryNoCross::class); + Filesystem::mount($sourceStorage, [], '/test/'); + + $targetPath = 'copied'; + if ($crossStorage) { + $targetStorage = $this->getTestStorage(true, TemporaryNoCross::class); + Filesystem::mount($targetStorage, [], '/test/target'); + $targetPath = 'target/copied'; + } + + $view = new View('/test'); + $fileMtime = time() - 3600; + $folderMtime = $fileMtime - 3600; + $view->touch('folder/bar.txt', $fileMtime); + $view->touch('folder', $folderMtime); + + $this->assertTrue($view->copy('folder', $targetPath)); + $this->assertSame($fileMtime, $view->filemtime($targetPath . '/bar.txt')); + $this->assertSame($folderMtime, $view->filemtime($targetPath)); + } + + public function testCopyDoesNotPreserveMtimeWhenDisabled(): void { + $storage = $this->getTestStorage(); + Filesystem::mount($storage, [], '/test/'); + + $view = new View('/test'); + $sourceMtime = time() - 3600; + $view->touch('foo.txt', $sourceMtime); + + $this->assertTrue($view->copy('foo.txt', 'copied.txt', false)); + $this->assertNotSame($sourceMtime, $view->filemtime('copied.txt')); + } + public function copyBetweenStorages($storage1, $storage2) { Filesystem::mount($storage1, [], '/'); Filesystem::mount($storage2, [], '/substorage'); From ac8aafb99283951f7bb6a391d53f54dd811d014d Mon Sep 17 00:00:00 2001 From: Madison Steiner <8176115+mh0pe@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:32:17 -0700 Subject: [PATCH 2/2] Update AUTHORS Signed-off-by: Madison Steiner <8176115+mh0pe@users.noreply.github.com> --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 648c3aa52212d..9e850db4974af 100644 --- a/AUTHORS +++ b/AUTHORS @@ -283,6 +283,7 @@ - Lukas Reschke - Lukas Stabe - Luke Policinski + - Madison Steiner - Magnus Walbeck - Maksim Sukharev - Marc Hefter