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