Skip to content
Open
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 @@ -65,7 +65,9 @@ public Stream OpenMetadataWriteStream()
"Pinned path-only registration leases do not authorize metadata writes.");
}

return _file.OpenIndependentWriteStream(
// Read+write, because the only consumer is a tag library that parses the container it
// is about to rewrite through this same stream.
return _file.OpenIndependentReadWriteStream(
bufferSize: 128 * 1024,
asynchronous: false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,15 @@ private static SafeFileHandle OpenRelativeFileUnix(
private static SafeFileHandle OpenRelativeFileForWriteUnix(
SafeFileHandle parentHandle,
string fileName,
string fullPath)
string fullPath,
bool readable = false)
{
var fd = OpenAt(
parentHandle.DangerousGetHandle().ToInt32(),
fileName,
UnixOpenFlags.OpenWriteNoFollow(),
readable
? UnixOpenFlags.OpenReadWriteNoFollow()
: UnixOpenFlags.OpenWriteNoFollow(),
mode: 0);
if (fd >= 0)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@ private static SafeFileHandle OpenRelativeFileStableDeleteWindows(
private static SafeFileHandle OpenRelativeFileForWriteWindows(
SafeFileHandle parentHandle,
string fileName,
string fullPath)
string fullPath,
bool readable = false)
{
var nameBuffer = Marshal.StringToHGlobalUni(fileName);
var unicodeStringPointer = IntPtr.Zero;
Expand All @@ -281,7 +282,8 @@ private static SafeFileHandle OpenRelativeFileForWriteWindows(
};
var status = NtCreateFile(
out var rawHandle,
GenericWrite | FileReadAttributes | Synchronize,
(readable ? GenericRead : 0u)
| GenericWrite | FileReadAttributes | Synchronize,
ref attributes,
out _,
IntPtr.Zero,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,28 @@ internal FileStream OpenIndependentReadStream(int bufferSize, bool asynchronous)
asynchronous);
}

internal FileStream OpenIndependentWriteStream(int bufferSize, bool asynchronous)
// Tag libraries rewrite a container in place: they parse the existing box or frame
// structure through the same stream they then write back to, so a write-only handle
// fails while still reading. The pinning is unaffected by the wider access mode — the
// handle is still opened relative to the pinned parent, still refuses to follow a
// link, and is still checked against the validated file object below.
internal FileStream OpenIndependentReadWriteStream(int bufferSize, bool asynchronous)
{
ThrowIfDisposed();
var handle = OperatingSystem.IsWindows()
? OpenRelativeFileForWriteWindows(
_parentHandle,
_fileName,
FullPath)
FullPath,
readable: true)
: OpenRelativeFileForWriteUnix(
_parentHandle,
_fileName,
FullPath);
FullPath,
readable: true);
return OpenVerifiedIndependentStream(
handle,
FileAccess.Write,
FileAccess.ReadWrite,
bufferSize,
asynchronous);
}
Expand Down
14 changes: 14 additions & 0 deletions listenarr.infrastructure/FileSystem/UnixOpenFlags.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ internal static int OpenWriteNoFollow()
return LinuxWriteOnly | noFollow | LinuxCloseOnExec;
}

// For a caller that has to read the file back through the same descriptor it writes to.
// Tag libraries do this: they parse the existing container through the stream they are
// about to rewrite, so a write-only descriptor fails partway through the parse.
internal static int OpenReadWriteNoFollow()
{
if (IsMacOSHost())
{
return MacReadWrite | MacNoFollow | MacCloseOnExec;
}

var (_, noFollow) = GetCurrentLinuxDirectorySafetyFlags();
return LinuxReadWrite | noFollow | LinuxCloseOnExec;
}

internal static int CreateWriteExclusiveNoFollow()
{
if (IsMacOSHost())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ public Task WriteAsinTagAsync(

private static void ApplyAsinTag(TagLib.File file, string asin)
{
if (file.Tag is TagLib.Mpeg4.AppleTag appleTag)
// An MPEG-4 file's Tag is a CombinedTag wrapping the Apple tag, never the AppleTag
// itself, so a type test on file.Tag matches nothing here and the save that follows
// writes an unchanged file while still reporting success. The tag has to be asked
// for by type. Only MPEG-4 answers to Apple, so mp3 and flac fall through as before.
if (file.GetTag(TagLib.TagTypes.Apple, create: true) is TagLib.Mpeg4.AppleTag appleTag)
appleTag.SetDashBox("com.apple.iTunes", "ASIN", asin);
else if (file.GetTag(TagLib.TagTypes.Id3v2) is TagLib.Id3v2.Tag id3Tag)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,32 @@ public async Task OpenMetadataWriteStream_PublicPathReplaced_DoesNotOpenReplacem
await File.ReadAllTextAsync(displacedPath));
}

[Fact]
public async Task OpenMetadataWriteStream_IsReadableSoATagLibraryCanParseWhatItRewrites()
{
var parent = FileService.GetTempDirectory(
"registration-lease-metadata-write-access");
var publicPath = await FileService.GetFileAsync(
parent,
"book.m4b",
"original generation");
using var lease = PinnedAudiobookFileRegistrationLease.Open(publicPath);

using var stream = lease.OpenMetadataWriteStream();

// TagLib reads the existing box structure back through the write stream before it
// saves, so a write-only handle throws NotSupportedException mid-parse and the tag is
// never written. The import still reports success, which is why this needs asserting
// rather than observing.
Assert.True(stream.CanWrite);
Assert.True(stream.CanRead);
Assert.True(stream.CanSeek);

var buffer = new byte[stream.Length];
Assert.Equal(buffer.Length, stream.Read(buffer, 0, buffer.Length));
Assert.Equal("original generation", System.Text.Encoding.UTF8.GetString(buffer));
}

[WindowsFact]
public async Task StableRegistrationLease_BlocksPublicPathReplacementUntilDisposed()
{
Expand Down
15 changes: 15 additions & 0 deletions tests/Features/Infrastructure/FileSystem/UnixOpenFlagsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ public void GetLinuxDirectorySafetyFlags_RejectsArchitecturesListenarrDoesNotRel
UnixOpenFlags.GetLinuxDirectorySafetyFlags(architecture));
}

[Fact]
public void OpenReadWriteNoFollow_AsksForReadAccessWhereOpenWriteDoesNot()
{
// Given / When
var writeOnly = UnixOpenFlags.OpenWriteNoFollow();
var readWrite = UnixOpenFlags.OpenReadWriteNoFollow();

// Then: the access mode is the low two bits, and only that differs. Asserting the
// difference rather than a literal keeps this honest on both released architectures,
// since the noFollow bit is not the same value on arm64 and x64.
Assert.Equal(1, writeOnly & 0x3);
Assert.Equal(2, readWrite & 0x3);
Assert.Equal(writeOnly & ~0x3, readWrite & ~0x3);
}

[Fact]
public void EnsureMacOSArchitectureSupported_AcceptsReleasedX64Target()
{
Expand Down