diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FilePublication.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FilePublication.cs index a681dd0f9..5c6070fea 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FilePublication.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.FilePublication.cs @@ -23,13 +23,126 @@ private static int TryRenameRelativeEntryNoReplaceLinux( "The non-throwing no-replace rename probe is Linux-specific."); } - var result = RenameAtNoReplaceLinux( + return RenameNoReplaceLinuxCore( sourceDirectoryHandle.DangerousGetHandle().ToInt32(), sourceName, destinationDirectoryHandle.DangerousGetHandle().ToInt32(), finalName, - RenameNoReplace); - return result == 0 ? 0 : Marshal.GetLastWin32Error(); + NoReplaceRenamePrimitives.Native); + } + + private const int UnixInvalidArgument = 22; + private const int UnixFunctionNotImplemented = 38; + private const int UnixOperationNotSupported = 95; + + /// + /// The errno values under which renameat2(RENAME_NOREPLACE) means + /// "this filesystem cannot do that", not "the rename was refused": NFS + /// returns EINVAL for any rename flag, pre-3.15 kernels and some + /// FUSE/overlay stacks return ENOSYS or EOPNOTSUPP. + /// + internal static bool IsNoReplaceRenameUnsupportedError(int nativeErrorCode) => + nativeErrorCode is UnixInvalidArgument + or UnixFunctionNotImplemented + or UnixOperationNotSupported; + + /// + /// Native primitives behind the Linux no-replace rename, as delegates so the + /// fallback sequencing is unit-testable without a filesystem that refuses + /// RENAME_NOREPLACE. Each call captures errno immediately. + /// + internal sealed record NoReplaceRenamePrimitives( + Func RenameNoReplace, + Func Link, + Func Unlink) + { + internal static readonly NoReplaceRenamePrimitives Native = new( + (sourceFd, source, destinationFd, destination) => + { + var result = RenameAtNoReplaceLinux( + sourceFd, + source, + destinationFd, + destination, + PinnedDirectoryCreation.RenameNoReplace); + return (result, result == 0 ? 0 : Marshal.GetLastWin32Error()); + }, + (sourceFd, source, destinationFd, destination) => + { + var result = LinkAt(sourceFd, source, destinationFd, destination, 0); + return (result, result == 0 ? 0 : Marshal.GetLastWin32Error()); + }, + (directoryFd, name) => + { + var result = UnlinkAt(directoryFd, name, 0); + return (result, result == 0 ? 0 : Marshal.GetLastWin32Error()); + }); + } + + /// + /// Atomic no-replace rename on Linux with a fallback for filesystems that + /// reject renameat2(RENAME_NOREPLACE) outright (live case: a library + /// on NFS 4.2, where every same-volume file move — split, transfer, + /// organize — failed with EINVAL). The fallback keeps the no-replace + /// contract without a check-then-act race: linkat refuses to + /// overwrite an existing destination (EEXIST) and gives the destination the + /// SAME inode, so pinned identity proofs still hold; the source name is then + /// unlinked. Returns 0 on success or the errno callers should reason about: + /// EEXIST when the destination already exists, the ORIGINAL unsupported + /// errno when the filesystem cannot hard-link either (so existing + /// "unsupported → verified copy" fallbacks still engage), or the unlink + /// errno when the source name could not be removed (the new link is rolled + /// back so the move is not half-applied). + /// + internal static int RenameNoReplaceLinuxCore( + int sourceDirectoryFileDescriptor, + string sourceName, + int destinationDirectoryFileDescriptor, + string finalName, + NoReplaceRenamePrimitives primitives) + { + ArgumentNullException.ThrowIfNull(primitives); + + var rename = primitives.RenameNoReplace( + sourceDirectoryFileDescriptor, + sourceName, + destinationDirectoryFileDescriptor, + finalName); + if (rename.Result == 0) + { + return 0; + } + if (!IsNoReplaceRenameUnsupportedError(rename.Errno)) + { + return rename.Errno; + } + + var link = primitives.Link( + sourceDirectoryFileDescriptor, + sourceName, + destinationDirectoryFileDescriptor, + finalName); + if (link.Result != 0) + { + // EEXIST is the no-replace refusal callers expect; anything else + // (EPERM/EXDEV/EMLINK/EOPNOTSUPP: no hard links here) means the + // filesystem supports neither primitive — report the original + // unsupported errno so the caller's copy fallback can take over. + return link.Errno == UnixAlreadyExists ? link.Errno : rename.Errno; + } + + var unlink = primitives.Unlink(sourceDirectoryFileDescriptor, sourceName); + if (unlink.Result == 0 || unlink.Errno == UnixNoEntry) + { + // ENOENT: the source name vanished underneath us but the inode now + // lives at the destination — the move is complete. + return 0; + } + + // The source name could not be removed: undo the new link so the file + // is not left published under two names, then surface the real error. + primitives.Unlink(destinationDirectoryFileDescriptor, finalName); + return unlink.Errno; } private static void RenameRelativeEntry( @@ -56,29 +169,41 @@ private static void RenameRelativeEntry( var destinationDirectoryFileDescriptor = destinationDirectoryHandle .DangerousGetHandle() .ToInt32(); - var result = replaceExisting - ? RenameAtUnix( + int nativeError; + if (replaceExisting) + { + var result = RenameAtUnix( sourceDirectoryFileDescriptor, sourceName, destinationDirectoryFileDescriptor, - finalName) - : OperatingSystem.IsMacOS() - ? RenameAtExclusiveMac( + finalName); + nativeError = result == 0 ? 0 : Marshal.GetLastWin32Error(); + } + else if (OperatingSystem.IsMacOS()) + { + var result = RenameAtExclusiveMac( sourceDirectoryFileDescriptor, sourceName, destinationDirectoryFileDescriptor, finalName, - RenameExclusiveMac) - : RenameAtNoReplaceLinux( + RenameExclusiveMac); + nativeError = result == 0 ? 0 : Marshal.GetLastWin32Error(); + } + else + { + // Same no-replace contract as the probing variant, including the + // hard-link fallback for filesystems that reject RENAME_NOREPLACE. + nativeError = RenameNoReplaceLinuxCore( sourceDirectoryFileDescriptor, sourceName, destinationDirectoryFileDescriptor, finalName, - RenameNoReplace); - if (result != 0) + NoReplaceRenamePrimitives.Native); + } + if (nativeError != 0) { throw new Win32Exception( - Marshal.GetLastWin32Error(), + nativeError, "Could not publish a pinned filesystem entry relative to its owned directory."); } } diff --git a/tests/Features/Infrastructure/FileSystem/NoReplaceRenameFallbackTests.cs b/tests/Features/Infrastructure/FileSystem/NoReplaceRenameFallbackTests.cs new file mode 100644 index 000000000..14f03a38b --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/NoReplaceRenameFallbackTests.cs @@ -0,0 +1,149 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using Listenarr.Infrastructure.FileSystem; +using Listenarr.Tests.Common; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "NoReplaceRenameFallbackTests")] +[Trait("Category", "Infrastructure")] +public sealed class NoReplaceRenameFallbackTests : BaseTests +{ + private const int EPERM = 1; + private const int ENOENT = 2; + private const int EACCES = 13; + private const int EEXIST = 17; + private const int EXDEV = 18; + private const int EINVAL = 22; + private const int ENOSYS = 38; + private const int EOPNOTSUPP = 95; + + private sealed class Script + { + public readonly List Calls = new(); + public int RenameErrno; + public int LinkErrno; + public int UnlinkSourceErrno; + + public PinnedDirectoryCreation.NoReplaceRenamePrimitives Primitives => new( + (sfd, s, dfd, d) => + { + Calls.Add($"rename2 {sfd}/{s} -> {dfd}/{d}"); + return RenameErrno == 0 ? (0, 0) : (-1, RenameErrno); + }, + (sfd, s, dfd, d) => + { + Calls.Add($"link {sfd}/{s} -> {dfd}/{d}"); + return LinkErrno == 0 ? (0, 0) : (-1, LinkErrno); + }, + (fd, name) => + { + Calls.Add($"unlink {fd}/{name}"); + if (fd == 10 && UnlinkSourceErrno != 0) return (-1, UnlinkSourceErrno); + return (0, 0); + }); + } + + private static int Run(Script script) => + PinnedDirectoryCreation.RenameNoReplaceLinuxCore(10, "a.m4b", 20, "b.m4b", script.Primitives); + + [Fact] + public void RenameSupported_UsesRenameOnly() + { + var script = new Script(); + Assert.Equal(0, Run(script)); + Assert.Equal(new[] { "rename2 10/a.m4b -> 20/b.m4b" }, script.Calls); + } + + [Fact] + public void RenameRefusedBecauseDestinationExists_ReportsEexistWithoutFallback() + { + var script = new Script { RenameErrno = EEXIST }; + Assert.Equal(EEXIST, Run(script)); + Assert.Single(script.Calls); + } + + [Theory] + [InlineData(EINVAL)] + [InlineData(ENOSYS)] + [InlineData(EOPNOTSUPP)] + public void RenameUnsupported_FallsBackToLinkThenUnlinkSource(int unsupported) + { + // NFS (EINVAL), old kernels (ENOSYS), some FUSE stacks (EOPNOTSUPP): + // link the inode under the new name, then drop the old name. + var script = new Script { RenameErrno = unsupported }; + Assert.Equal(0, Run(script)); + Assert.Equal( + new[] { "rename2 10/a.m4b -> 20/b.m4b", "link 10/a.m4b -> 20/b.m4b", "unlink 10/a.m4b" }, + script.Calls); + } + + [Fact] + public void RenameUnsupported_DestinationExists_LinkRefusesAndNothingIsUnlinked() + { + // The no-replace contract survives the fallback: linkat refuses to + // overwrite, and the source is left exactly where it was. + var script = new Script { RenameErrno = EINVAL, LinkErrno = EEXIST }; + Assert.Equal(EEXIST, Run(script)); + Assert.DoesNotContain(script.Calls, c => c.StartsWith("unlink")); + } + + [Theory] + [InlineData(EPERM)] + [InlineData(EXDEV)] + [InlineData(EOPNOTSUPP)] + public void RenameUnsupported_HardLinksUnsupportedToo_ReportsOriginalUnsupportedErrno(int linkErrno) + { + // Callers classify 22/38/95 as "native rename unsupported" and switch to + // their verified-copy fallback — that classification must still fire. + var script = new Script { RenameErrno = EINVAL, LinkErrno = linkErrno }; + Assert.Equal(EINVAL, Run(script)); + Assert.DoesNotContain(script.Calls, c => c.StartsWith("unlink")); + } + + [Fact] + public void RenameUnsupported_SourceUnlinkFails_RollsBackTheNewLink() + { + var script = new Script { RenameErrno = EINVAL, UnlinkSourceErrno = EACCES }; + Assert.Equal(EACCES, Run(script)); + Assert.Equal("unlink 10/a.m4b", script.Calls[2]); + Assert.Equal("unlink 20/b.m4b", script.Calls[3]); + } + + [Fact] + public void RenameUnsupported_SourceAlreadyGoneAfterLink_IsSuccess() + { + // The inode now lives at the destination; a vanished source name is a + // completed move, not a failure. + var script = new Script { RenameErrno = EINVAL, UnlinkSourceErrno = ENOENT }; + Assert.Equal(0, Run(script)); + Assert.Equal(3, script.Calls.Count); + } + + [Theory] + [InlineData(EINVAL, true)] + [InlineData(ENOSYS, true)] + [InlineData(EOPNOTSUPP, true)] + [InlineData(EEXIST, false)] + [InlineData(EXDEV, false)] + [InlineData(EACCES, false)] + public void IsNoReplaceRenameUnsupportedError_ClassifiesErrno(int errno, bool unsupported) + { + Assert.Equal(unsupported, PinnedDirectoryCreation.IsNoReplaceRenameUnsupportedError(errno)); + } +}