mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-01-23 23:20:51 +01:00
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:
parent
9843f6783c
commit
117704b86c
4 changed files with 255 additions and 2 deletions
67
Jellyfin.Server/Infrastructure/SafeTimestampFileInfo.cs
Normal file
67
Jellyfin.Server/Infrastructure/SafeTimestampFileInfo.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
57
Jellyfin.Server/Infrastructure/SafeTimestampFileProvider.cs
Normal file
57
Jellyfin.Server/Infrastructure/SafeTimestampFileProvider.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
128
tests/Jellyfin.Server.Tests/SafeTimestampFileInfoTests.cs
Normal file
128
tests/Jellyfin.Server.Tests/SafeTimestampFileInfoTests.cs
Normal 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!));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue