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));
+ }
+}