Fix #15907: Handle invalid Win32 FileTime in static files

Add SafeTimestampFileProvider and SafeTimestampFileInfo wrappers to sanitize file timestamps that are before the Win32 epoch (1601-01-01), which can occur in Docker containers or on certain filesystems. Invalid timestamps are clamped to Unix epoch (1970-01-01) to prevent ArgumentOutOfRangeException in StaticFileMiddleware.
This commit is contained in:
ZeusCraft10 2025-12-31 12:08:45 -05:00
parent 9843f6783c
commit 117704b86c
4 changed files with 255 additions and 2 deletions

View file

@ -0,0 +1,67 @@
using System;
using System.IO;
using Microsoft.Extensions.FileProviders;
namespace Jellyfin.Server.Infrastructure
{
/// <summary>
/// An <see cref="IFileInfo"/> wrapper that sanitizes <see cref="LastModified"/> timestamps
/// to ensure they are valid Win32 FileTimes.
/// </summary>
/// <remarks>
/// This wrapper prevents <see cref="ArgumentOutOfRangeException"/> in
/// <see cref="Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware"/> when serving files
/// with timestamps before 1601-01-01 (the Win32 epoch), which can occur in Docker containers
/// or on certain filesystems.
/// </remarks>
public class SafeTimestampFileInfo : IFileInfo
{
/// <summary>
/// The minimum valid Win32 FileTime is 1601-01-01. We use 1601-01-02 for safety margin.
/// </summary>
private static readonly DateTimeOffset _minValidWin32Time =
new DateTimeOffset(1601, 1, 2, 0, 0, 0, TimeSpan.Zero);
/// <summary>
/// Safe fallback timestamp (Unix epoch: 1970-01-01).
/// </summary>
private static readonly DateTimeOffset _safeFallbackTime =
new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
private readonly IFileInfo _inner;
/// <summary>
/// Initializes a new instance of the <see cref="SafeTimestampFileInfo"/> class.
/// </summary>
/// <param name="inner">The inner <see cref="IFileInfo"/> to wrap.</param>
public SafeTimestampFileInfo(IFileInfo inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
/// <inheritdoc />
public bool Exists => _inner.Exists;
/// <inheritdoc />
public long Length => _inner.Length;
/// <inheritdoc />
public string? PhysicalPath => _inner.PhysicalPath;
/// <inheritdoc />
public string Name => _inner.Name;
/// <inheritdoc />
/// <remarks>
/// Returns the original timestamp if valid, otherwise returns 1970-01-01 (Unix epoch).
/// </remarks>
public DateTimeOffset LastModified =>
_inner.LastModified < _minValidWin32Time ? _safeFallbackTime : _inner.LastModified;
/// <inheritdoc />
public bool IsDirectory => _inner.IsDirectory;
/// <inheritdoc />
public Stream CreateReadStream() => _inner.CreateReadStream();
}
}

View file

@ -0,0 +1,57 @@
using System;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.FileProviders.Physical;
using Microsoft.Extensions.Primitives;
namespace Jellyfin.Server.Infrastructure
{
/// <summary>
/// An <see cref="IFileProvider"/> wrapper that sanitizes file timestamps to ensure
/// they are valid Win32 FileTimes.
/// </summary>
/// <remarks>
/// This wrapper prevents <see cref="ArgumentOutOfRangeException"/> in
/// <see cref="Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware"/> when serving files
/// with timestamps before 1601-01-01 (the Win32 epoch), which can occur in Docker containers
/// or on certain filesystems.
/// </remarks>
public sealed class SafeTimestampFileProvider : IFileProvider, IDisposable
{
private readonly PhysicalFileProvider _inner;
/// <summary>
/// Initializes a new instance of the <see cref="SafeTimestampFileProvider"/> class.
/// </summary>
/// <param name="root">The root directory for this provider.</param>
public SafeTimestampFileProvider(string root)
{
ArgumentNullException.ThrowIfNull(root);
_inner = new PhysicalFileProvider(root);
}
/// <inheritdoc />
public IFileInfo GetFileInfo(string subpath)
{
var fileInfo = _inner.GetFileInfo(subpath);
return new SafeTimestampFileInfo(fileInfo);
}
/// <inheritdoc />
public IDirectoryContents GetDirectoryContents(string subpath)
{
return _inner.GetDirectoryContents(subpath);
}
/// <inheritdoc />
public IChangeToken Watch(string filter)
{
return _inner.Watch(filter);
}
/// <inheritdoc />
public void Dispose()
{
_inner.Dispose();
}
}
}

View file

@ -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) =>

View file

@ -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<IFileInfo>();
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<IFileInfo>();
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<IFileInfo>();
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<IFileInfo>();
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<IFileInfo>();
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<IFileInfo>();
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<ArgumentNullException>(() => new SafeTimestampFileInfo(null!));
}
}
}