Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// The errno values under which <c>renameat2(RENAME_NOREPLACE)</c> 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.
/// </summary>
internal static bool IsNoReplaceRenameUnsupportedError(int nativeErrorCode) =>
nativeErrorCode is UnixInvalidArgument
or UnixFunctionNotImplemented
or UnixOperationNotSupported;

/// <summary>
/// Native primitives behind the Linux no-replace rename, as delegates so the
/// fallback sequencing is unit-testable without a filesystem that refuses
/// <c>RENAME_NOREPLACE</c>. Each call captures errno immediately.
/// </summary>
internal sealed record NoReplaceRenamePrimitives(
Func<int, string, int, string, (int Result, int Errno)> RenameNoReplace,
Func<int, string, int, string, (int Result, int Errno)> Link,
Func<int, string, (int Result, int Errno)> 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());
});
}

/// <summary>
/// Atomic no-replace rename on Linux with a fallback for filesystems that
/// reject <c>renameat2(RENAME_NOREPLACE)</c> 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: <c>linkat</c> 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).
/// </summary>
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(
Expand All @@ -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.");
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<string> 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));
}
}