fix(files): do not panic on pre-EPOCH files (#3922)

This commit is contained in:
Yuki Okushi 2026-02-14 10:25:48 +09:00 committed by GitHub
parent 0fb2527c60
commit f31f9bc92c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 122 additions and 8 deletions

34
Cargo.lock generated
View File

@ -57,6 +57,7 @@ dependencies = [
"bytes", "bytes",
"derive_more", "derive_more",
"env_logger", "env_logger",
"filetime",
"futures-core", "futures-core",
"http-range", "http-range",
"log", "log",
@ -1263,6 +1264,17 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -1857,6 +1869,17 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libredox"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.10.0",
"libc",
"redox_syscall 0.7.1",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.11.0" version = "0.11.0"
@ -2094,7 +2117,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"redox_syscall", "redox_syscall 0.5.18",
"smallvec", "smallvec",
"windows-link", "windows-link",
] ]
@ -2357,6 +2380,15 @@ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
] ]
[[package]]
name = "redox_syscall"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
dependencies = [
"bitflags 2.10.0",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.3" version = "1.12.3"

View File

@ -4,8 +4,10 @@
- Add `Files::try_compressed()` to support serving pre-compressed static files [#2615] - Add `Files::try_compressed()` to support serving pre-compressed static files [#2615]
- Fix handling of `bytes=0-` - Fix handling of `bytes=0-`
- Fix `NamedFile` panic when serving files with pre-UNIX epoch modification times. [#2748]
[#2615]: https://github.com/actix/actix-web/pull/2615 [#2615]: https://github.com/actix/actix-web/pull/2615
[#2748]: https://github.com/actix/actix-web/issues/2748
## 0.6.10 ## 0.6.10

View File

@ -44,6 +44,7 @@ actix-rt = "2.7"
actix-test = "0.1" actix-test = "0.1"
actix-web = "4" actix-web = "4"
env_logger = "0.11" env_logger = "0.11"
filetime = "0.2"
tempfile = "3.2" tempfile = "3.2"
[lints] [lints]

View File

@ -405,7 +405,9 @@ impl NamedFile {
/// Creates an `ETag` in a format is similar to Apache's. /// Creates an `ETag` in a format is similar to Apache's.
pub(crate) fn etag(&self) -> Option<header::EntityTag> { pub(crate) fn etag(&self) -> Option<header::EntityTag> {
self.modified.as_ref().map(|mtime| { let mtime = self.modified?;
Some({
let ino = { let ino = {
#[cfg(unix)] #[cfg(unix)]
{ {
@ -421,22 +423,50 @@ impl NamedFile {
} }
}; };
let dur = mtime // Don't panic for pre-epoch modification times. Encode the timestamp as seconds and
.duration_since(UNIX_EPOCH) // sub-second nanoseconds relative to the UNIX epoch, allowing negative values.
.expect("modification time must be after epoch"); let (secs, nanos) = match mtime.duration_since(UNIX_EPOCH) {
Ok(dur) => (dur.as_secs() as i64, dur.subsec_nanos()),
Err(err) => {
let dur = err.duration();
// For timestamps before the epoch, represent the time as a negative seconds
// offset with positive nanoseconds (like POSIX timespec).
if dur.subsec_nanos() == 0 {
(-(dur.as_secs() as i64), 0)
} else {
(
-(dur.as_secs() as i64) - 1,
1_000_000_000 - dur.subsec_nanos(),
)
}
}
};
header::EntityTag::new_strong(format!( header::EntityTag::new_strong(format!(
"{:x}:{:x}:{:x}:{:x}", "{:x}:{:x}:{:x}:{:x}",
ino, ino,
self.md.len(), self.md.len(),
dur.as_secs(), secs as u64,
dur.subsec_nanos() nanos
)) ))
}) })
} }
pub(crate) fn last_modified(&self) -> Option<header::HttpDate> { pub(crate) fn last_modified(&self) -> Option<header::HttpDate> {
self.modified.map(|mtime| mtime.into()) let mtime = self.modified?;
// avoid panic in `httpdate` crate when formatting as an HTTP date
// see: https://github.com/actix/actix-web/issues/2748
//
// httpdate supports dates in range [1970, 9999); see:
// https://github.com/seanmonstar/httpdate/blob/v1.0.3/src/date.rs
let dur = mtime.duration_since(UNIX_EPOCH).ok()?;
if dur.as_secs() >= 253_402_300_800 {
return None;
}
Some(mtime.into())
} }
/// Creates an `HttpResponse` with file as a streaming body. /// Creates an `HttpResponse` with file as a streaming body.

View File

@ -0,0 +1,49 @@
use std::time::UNIX_EPOCH;
use actix_files::NamedFile;
use actix_web::{
http::{header, StatusCode},
test, web, App,
};
use filetime::{set_file_mtime, FileTime};
use tempfile::tempdir;
#[actix_web::test]
async fn serves_file_with_pre_epoch_mtime() {
let dir = tempdir().unwrap();
let path = dir.path().join("pre_epoch.txt");
std::fs::write(&path, b"hello").unwrap();
// set mtime to before UNIX epoch; this used to panic during ETag/Last-Modified generation
set_file_mtime(&path, FileTime::from_unix_time(-60, 0)).unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
assert!(
mtime < UNIX_EPOCH,
"fixture mtime should be before UNIX_EPOCH"
);
let srv = {
let path = path.clone();
test::init_service(App::new().default_service(web::to(move || {
let path = path.clone();
async move { NamedFile::open_async(path).await.unwrap() }
})))
.await
};
let req = test::TestRequest::with_uri("/").to_request();
let res = test::call_service(&srv, req).await;
assert_eq!(res.status(), StatusCode::OK);
// ETag is still generated even for pre-epoch times.
assert!(res.headers().contains_key(header::ETAG));
// HTTP-date formatting in the httpdate crate does not support pre-epoch times.
assert!(!res.headers().contains_key(header::LAST_MODIFIED));
let body = test::read_body(res).await;
assert_eq!(&body[..], b"hello");
}