diff --git a/Jellyfin.Server/Infrastructure/SafeTimestampFileInfo.cs b/Jellyfin.Server/Infrastructure/SafeTimestampFileInfo.cs new file mode 100644 index 0000000000..ac2fed2e75 --- /dev/null +++ b/Jellyfin.Server/Infrastructure/SafeTimestampFileInfo.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using Microsoft.Extensions.FileProviders; + +namespace Jellyfin.Server.Infrastructure +{ + /// + /// An wrapper that sanitizes timestamps + /// to ensure they are valid Win32 FileTimes. + /// + /// + /// This wrapper prevents in + /// when serving files + /// with timestamps before 1601-01-01 (the Win32 epoch), which can occur in Docker containers + /// or on certain filesystems. + /// + public class SafeTimestampFileInfo : IFileInfo + { + /// + /// The minimum valid Win32 FileTime is 1601-01-01. We use 1601-01-02 for safety margin. + /// + private static readonly DateTimeOffset _minValidWin32Time = + new DateTimeOffset(1601, 1, 2, 0, 0, 0, TimeSpan.Zero); + + /// + /// Safe fallback timestamp (Unix epoch: 1970-01-01). + /// + private static readonly DateTimeOffset _safeFallbackTime = + new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private readonly IFileInfo _inner; + + /// + /// Initializes a new instance of the class. + /// + /// The inner to wrap. + public SafeTimestampFileInfo(IFileInfo inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + /// + public bool Exists => _inner.Exists; + + /// + public long Length => _inner.Length; + + /// + public string? PhysicalPath => _inner.PhysicalPath; + + /// + public string Name => _inner.Name; + + /// + /// + /// Returns the original timestamp if valid, otherwise returns 1970-01-01 (Unix epoch). + /// + public DateTimeOffset LastModified => + _inner.LastModified < _minValidWin32Time ? _safeFallbackTime : _inner.LastModified; + + /// + public bool IsDirectory => _inner.IsDirectory; + + /// + public Stream CreateReadStream() => _inner.CreateReadStream(); + } +} diff --git a/Jellyfin.Server/Infrastructure/SafeTimestampFileProvider.cs b/Jellyfin.Server/Infrastructure/SafeTimestampFileProvider.cs new file mode 100644 index 0000000000..db986fc4e3 --- /dev/null +++ b/Jellyfin.Server/Infrastructure/SafeTimestampFileProvider.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; +using Microsoft.Extensions.Primitives; + +namespace Jellyfin.Server.Infrastructure +{ + /// + /// An wrapper that sanitizes file timestamps to ensure + /// they are valid Win32 FileTimes. + /// + /// + /// This wrapper prevents in + /// when serving files + /// with timestamps before 1601-01-01 (the Win32 epoch), which can occur in Docker containers + /// or on certain filesystems. + /// + public sealed class SafeTimestampFileProvider : IFileProvider, IDisposable + { + private readonly PhysicalFileProvider _inner; + + /// + /// Initializes a new instance of the class. + /// + /// The root directory for this provider. + public SafeTimestampFileProvider(string root) + { + ArgumentNullException.ThrowIfNull(root); + _inner = new PhysicalFileProvider(root); + } + + /// + public IFileInfo GetFileInfo(string subpath) + { + var fileInfo = _inner.GetFileInfo(subpath); + return new SafeTimestampFileInfo(fileInfo); + } + + /// + public IDirectoryContents GetDirectoryContents(string subpath) + { + return _inner.GetDirectoryContents(subpath); + } + + /// + public IChangeToken Watch(string filter) + { + return _inner.Watch(filter); + } + + /// + public void Dispose() + { + _inner.Dispose(); + } + } +} diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index f6a4ae7d6e..a334a09504 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -16,6 +16,7 @@ using Jellyfin.Networking.HappyEyeballs; using Jellyfin.Server.Extensions; using Jellyfin.Server.HealthChecks; using Jellyfin.Server.Implementations.Extensions; +using Jellyfin.Server.Infrastructure; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Extensions; @@ -182,12 +183,12 @@ namespace Jellyfin.Server extensionProvider.Mappings.Add(".mem", MediaTypeNames.Application.Octet); mainApp.UseDefaultFiles(new DefaultFilesOptions { - FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath), + FileProvider = new SafeTimestampFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath), RequestPath = "/web" }); mainApp.UseStaticFiles(new StaticFileOptions { - FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath), + FileProvider = new SafeTimestampFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath), RequestPath = "/web", ContentTypeProvider = extensionProvider, OnPrepareResponse = (context) => diff --git a/tests/Jellyfin.Server.Tests/SafeTimestampFileInfoTests.cs b/tests/Jellyfin.Server.Tests/SafeTimestampFileInfoTests.cs new file mode 100644 index 0000000000..22c678cedd --- /dev/null +++ b/tests/Jellyfin.Server.Tests/SafeTimestampFileInfoTests.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using Jellyfin.Server.Infrastructure; +using Microsoft.Extensions.FileProviders; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Tests +{ + public class SafeTimestampFileInfoTests + { + private static readonly DateTimeOffset ValidTimestamp = new DateTimeOffset(2024, 12, 31, 12, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset PreWin32Timestamp = new DateTimeOffset(1600, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset UnixEpoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public void LastModified_WithValidTimestamp_ReturnsOriginal() + { + // Arrange + var mockFileInfo = new Mock(); + mockFileInfo.Setup(f => f.LastModified).Returns(ValidTimestamp); + + var safeFileInfo = new SafeTimestampFileInfo(mockFileInfo.Object); + + // Act + var result = safeFileInfo.LastModified; + + // Assert + Assert.Equal(ValidTimestamp, result); + } + + [Fact] + public void LastModified_WithDateMinValue_ReturnsSafeFallback() + { + // Arrange + var mockFileInfo = new Mock(); + mockFileInfo.Setup(f => f.LastModified).Returns(DateTimeOffset.MinValue); + + var safeFileInfo = new SafeTimestampFileInfo(mockFileInfo.Object); + + // Act + var result = safeFileInfo.LastModified; + + // Assert + Assert.Equal(UnixEpoch, result); + } + + [Fact] + public void LastModified_WithPre1601Timestamp_ReturnsSafeFallback() + { + // Arrange + var mockFileInfo = new Mock(); + mockFileInfo.Setup(f => f.LastModified).Returns(PreWin32Timestamp); + + var safeFileInfo = new SafeTimestampFileInfo(mockFileInfo.Object); + + // Act + var result = safeFileInfo.LastModified; + + // Assert + Assert.Equal(UnixEpoch, result); + } + + [Fact] + public void LastModified_WithWin32EpochPlusOneDay_ReturnsOriginal() + { + // Arrange - exactly at the boundary (1601-01-02 should be valid) + var boundaryTimestamp = new DateTimeOffset(1601, 1, 2, 0, 0, 0, TimeSpan.Zero); + var mockFileInfo = new Mock(); + mockFileInfo.Setup(f => f.LastModified).Returns(boundaryTimestamp); + + var safeFileInfo = new SafeTimestampFileInfo(mockFileInfo.Object); + + // Act + var result = safeFileInfo.LastModified; + + // Assert + Assert.Equal(boundaryTimestamp, result); + } + + [Fact] + public void Properties_DelegateCorrectly() + { + // Arrange + var mockFileInfo = new Mock(); + mockFileInfo.Setup(f => f.Exists).Returns(true); + mockFileInfo.Setup(f => f.Length).Returns(12345); + mockFileInfo.Setup(f => f.PhysicalPath).Returns("/path/to/file.txt"); + mockFileInfo.Setup(f => f.Name).Returns("file.txt"); + mockFileInfo.Setup(f => f.IsDirectory).Returns(false); + mockFileInfo.Setup(f => f.LastModified).Returns(ValidTimestamp); + + var safeFileInfo = new SafeTimestampFileInfo(mockFileInfo.Object); + + // Act & Assert + Assert.True(safeFileInfo.Exists); + Assert.Equal(12345, safeFileInfo.Length); + Assert.Equal("/path/to/file.txt", safeFileInfo.PhysicalPath); + Assert.Equal("file.txt", safeFileInfo.Name); + Assert.False(safeFileInfo.IsDirectory); + } + + [Fact] + public void CreateReadStream_DelegatesCorrectly() + { + // Arrange + using var expectedStream = new MemoryStream(); + var mockFileInfo = new Mock(); + mockFileInfo.Setup(f => f.CreateReadStream()).Returns(expectedStream); + mockFileInfo.Setup(f => f.LastModified).Returns(ValidTimestamp); + + var safeFileInfo = new SafeTimestampFileInfo(mockFileInfo.Object); + + // Act + var result = safeFileInfo.CreateReadStream(); + + // Assert + Assert.Same(expectedStream, result); + } + + [Fact] + public void Constructor_WithNullInner_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new SafeTimestampFileInfo(null!)); + } + } +}