diff --git a/files/localfilesystem.cpp b/files/localfilesystem.cpp index a26316f..87223d3 100644 --- a/files/localfilesystem.cpp +++ b/files/localfilesystem.cpp @@ -503,9 +503,45 @@ std::string LocalFileSystem::link(const std::string& source, const std::string& } } +// Where names ignore case, the new case of a name finds the item itself. A hard link +// in the same folder is the same inode too, but only under another name. +static bool isNewCase(const std::string& source, const std::string& destination) +{ + const std::string sourceParent = canonicalPath(absolutePathOf(source)); + if (fileNameOf(source) == fileNameOf(destination) || sourceParent.empty() + || sourceParent != canonicalPath(absolutePathOf(destination))) + return false; + + struct stat a, b; + return ::lstat(native(source).c_str(), &a) == 0 && ::lstat(native(destination).c_str(), &b) == 0 + && a.st_dev == b.st_dev && a.st_ino == b.st_ino && (S_ISDIR(a.st_mode) || a.st_nlink == 1); +} + std::string LocalFileSystem::move(const std::string& source, const std::string& destination, ConflictPolicy policy, OperationContext& ctx) { + if (isNewCase(source, destination)) { + ctx.checkpoint(source); + Parent input(source), output(destination); + + // Renaming an item to itself does nothing, so it goes by way of another name. + const std::string temporary = temporaryName(".cutefish-rename-"); + if (::syscall(SYS_renameat2, input.fd.value, input.name.c_str(), input.fd.value, temporary.c_str(), + RENAME_NOREPLACE) + != 0) + throw Failure(errno, source); + + if (::syscall(SYS_renameat2, input.fd.value, temporary.c_str(), output.fd.value, output.name.c_str(), + RENAME_NOREPLACE) + != 0) { + const int code = errno; + ::renameat(input.fd.value, temporary.c_str(), input.fd.value, input.name.c_str()); + throw Failure(code, destination); + } + + return destination; + } + validateDestination(source, destination); ctx.checkpoint(source); Parent input(source), output(destination); diff --git a/files/tests/files_test.cpp b/files/tests/files_test.cpp index ef21fff..28d1570 100644 --- a/files/tests/files_test.cpp +++ b/files/tests/files_test.cpp @@ -8,9 +8,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -618,6 +620,84 @@ TEST_F(FilesTest, RemoveStopsAtMounts) EXPECT_EQ(WEXITSTATUS(status), 0); } +TEST_F(FilesTest, RenameToANewCaseWhereNamesIgnoreCase) +{ + TemporaryDirectory dir; + ASSERT_TRUE(makePath(dir.filePath("mount"))); + + const uid_t uid = ::getuid(); + const gid_t gid = ::getgid(); + const pid_t pid = ::fork(); + ASSERT_GE(pid, 0); + if (pid == 0) { + if (::unshare(CLONE_NEWUSER | CLONE_NEWNS) != 0) + ::_exit(77); + + if (!writeProc("/proc/self/uid_map", "0 " + std::to_string(uid) + " 1") + || !writeProc("/proc/self/setgroups", "deny") + || !writeProc("/proc/self/gid_map", "0 " + std::to_string(gid) + " 1")) + ::_exit(77); + + if (::mount("none", dir.filePath("mount").c_str(), "tmpfs", 0, "casefold") != 0) + ::_exit(77); + + const std::string folder = dir.filePath("mount/folder"); + if (::mkdir(folder.c_str(), 0700) != 0) + ::_exit(2); + + const int fd = ::open(folder.c_str(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + int flags = 0; + if (fd < 0 || ::ioctl(fd, FS_IOC_GETFLAGS, &flags) != 0) + ::_exit(77); + flags |= FS_CASEFOLD_FL; + if (::ioctl(fd, FS_IOC_SETFLAGS, &flags) != 0) + ::_exit(77); + ::close(fd); + + try { + writeFile(folder + "/photo.JPG", "photo"); + if (::mkdir((folder + "/album").c_str(), 0700) != 0) + ::_exit(2); + + OperationContext ctx; + const bool file = LocalFileSystem::move(folder + "/photo.JPG", folder + "/photo.jpg", ConflictPolicy::Ask, ctx) + == folder + "/photo.jpg"; + const bool directory = + LocalFileSystem::move(folder + "/album", folder + "/Album", ConflictPolicy::Ask, ctx) == folder + "/Album"; + auto names = entries(folder); + std::sort(names.begin(), names.end()); + ::_exit(file && directory && names == std::vector{"Album", "photo.jpg"} + && readFile(folder + "/photo.jpg") == "photo" + ? 0 + : 1); + } catch (...) { + ::_exit(3); + } + } + + int status = 0; + ASSERT_EQ(::waitpid(pid, &status, 0), pid); + ASSERT_TRUE(WIFEXITED(status)); + if (WEXITSTATUS(status) == 77) + GTEST_SKIP() << "No filesystem that ignores case can be mounted here"; + EXPECT_EQ(WEXITSTATUS(status), 0); +} + +TEST_F(FilesTest, MovingAHardLinkOntoItsTwinIsRefused) +{ + TemporaryDirectory dir; + writeFile(dir.filePath("a"), "data"); + ASSERT_EQ(::link(dir.filePath("a").c_str(), dir.filePath("b").c_str()), 0); + + OperationContext ctx; + EXPECT_EQ(errorCode([&] { + LocalFileSystem::move(dir.filePath("a"), dir.filePath("b"), ConflictPolicy::Replace, ctx); + }), + EINVAL); + EXPECT_TRUE(exists(dir.filePath("a"))); + EXPECT_TRUE(exists(dir.filePath("b"))); +} + TEST_F(FilesTest, PermissionErrors) { if (::geteuid() == 0)