diff --git a/Cargo.lock b/Cargo.lock index aa289d720..ac784f41e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,6 +57,7 @@ dependencies = [ "bytes", "derive_more", "env_logger", + "filetime", "futures-core", "http-range", "log", @@ -1263,6 +1264,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "find-msvc-tools" version = "0.1.9" @@ -1857,6 +1869,17 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "linux-raw-sys" version = "0.11.0" @@ -2094,7 +2117,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2357,6 +2380,15 @@ dependencies = [ "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]] name = "regex" version = "1.12.3" diff --git a/actix-files/CHANGES.md b/actix-files/CHANGES.md index ef0b79ae4..f25a56228 100644 --- a/actix-files/CHANGES.md +++ b/actix-files/CHANGES.md @@ -4,8 +4,10 @@ - Add `Files::try_compressed()` to support serving pre-compressed static files [#2615] - 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 +[#2748]: https://github.com/actix/actix-web/issues/2748 ## 0.6.10 diff --git a/actix-files/Cargo.toml b/actix-files/Cargo.toml index 12f99708e..65e91cafd 100644 --- a/actix-files/Cargo.toml +++ b/actix-files/Cargo.toml @@ -44,6 +44,7 @@ actix-rt = "2.7" actix-test = "0.1" actix-web = "4" env_logger = "0.11" +filetime = "0.2" tempfile = "3.2" [lints] diff --git a/actix-files/src/named.rs b/actix-files/src/named.rs index 1a3c2b3a1..09e3706e1 100644 --- a/actix-files/src/named.rs +++ b/actix-files/src/named.rs @@ -405,7 +405,9 @@ impl NamedFile { /// Creates an `ETag` in a format is similar to Apache's. pub(crate) fn etag(&self) -> Option { - self.modified.as_ref().map(|mtime| { + let mtime = self.modified?; + + Some({ let ino = { #[cfg(unix)] { @@ -421,22 +423,50 @@ impl NamedFile { } }; - let dur = mtime - .duration_since(UNIX_EPOCH) - .expect("modification time must be after epoch"); + // Don't panic for pre-epoch modification times. Encode the timestamp as seconds and + // sub-second nanoseconds relative to the UNIX epoch, allowing negative values. + 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!( "{:x}:{:x}:{:x}:{:x}", ino, self.md.len(), - dur.as_secs(), - dur.subsec_nanos() + secs as u64, + nanos )) }) } pub(crate) fn last_modified(&self) -> Option { - 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. diff --git a/actix-files/tests/pre_epoch_mtime.rs b/actix-files/tests/pre_epoch_mtime.rs new file mode 100644 index 000000000..490ca6e98 --- /dev/null +++ b/actix-files/tests/pre_epoch_mtime.rs @@ -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"); +}