From 18709e9cbe385a7f55d8d274ac2582e18f1b107a Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 17 Jul 2026 11:23:52 -0400 Subject: [PATCH 1/4] wasi: do not panic on extreme file timestamps in DescriptorStat Datetime::try_from can fail when SystemTime is outside the i64-second range. datetime_from used unwrap(), so a guest that set extreme timestamps on a preopened path could crash the host on the next stat. Map conversion failure to a missing timestamp (Option::None) instead. Signed-off-by: Sebastien Tardif --- crates/wasi/src/filesystem.rs | 48 ++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/wasi/src/filesystem.rs b/crates/wasi/src/filesystem.rs index c6e89fef7895..80411e4ee83f 100644 --- a/crates/wasi/src/filesystem.rs +++ b/crates/wasi/src/filesystem.rs @@ -1,5 +1,5 @@ use crate::clocks::Datetime; -use crate::runtime::{AbortOnDropJoinHandle, spawn_blocking}; +use crate::runtime::{spawn_blocking, AbortOnDropJoinHandle}; use cap_primitives::fs::{DirOptions, FollowSymlinks, Metadata, OpenOptions, SystemTimeSpec}; use std::collections::hash_map; use std::sync::Arc; @@ -310,18 +310,29 @@ impl DescriptorStat { /// Creates a `DescriptorStat` from a `Metadata` plus the hard link /// count. fn new(meta: &Metadata, link_count: u64) -> Self { - fn datetime_from(t: std::time::SystemTime) -> Datetime { - // FIXME make this infallible or handle errors properly - Datetime::try_from(t).unwrap() + // Extreme SystemTime values can overflow Datetime's i64 seconds range. + // Treat those as missing timestamps instead of panicking the host + // when a guest stats a preopened path after set_times. + fn datetime_from(t: std::time::SystemTime) -> Option { + Datetime::try_from(t).ok() } Self { type_: meta.file_type().into(), link_count, size: meta.len(), - data_access_timestamp: meta.accessed().map(|t| datetime_from(t.into_std())).ok(), - data_modification_timestamp: meta.modified().map(|t| datetime_from(t.into_std())).ok(), - status_change_timestamp: meta.created().map(|t| datetime_from(t.into_std())).ok(), + data_access_timestamp: meta + .accessed() + .ok() + .and_then(|t| datetime_from(t.into_std())), + data_modification_timestamp: meta + .modified() + .ok() + .and_then(|t| datetime_from(t.into_std())), + status_change_timestamp: meta + .created() + .ok() + .and_then(|t| datetime_from(t.into_std())), } } } @@ -1167,3 +1178,26 @@ impl WasiFilesystemCtxView<'_> { Ok(results) } } + +#[cfg(test)] +mod datetime_from_tests { + use crate::clocks::Datetime; + use std::time::{Duration, SystemTime}; + + /// Extreme SystemTime values can fail `Datetime::try_from`. The old + /// `datetime_from` path used `.unwrap()`, which would panic the host + /// during `DescriptorStat` construction. Mapping with `.ok()` must + /// yield `None` instead. + #[test] + fn extreme_system_time_is_none_not_panic() { + // A wall time far enough before the Unix epoch that the absolute + // second count does not fit in i64. Representable as SystemTime on + // common platforms, but `Datetime::try_from` returns Err. The old + // host path used `.unwrap()` and would panic during DescriptorStat. + let extreme = SystemTime::UNIX_EPOCH + .checked_sub(Duration::from_secs((i64::MAX as u64) + 1)) + .expect("construct far-past SystemTime"); + assert!(Datetime::try_from(extreme).is_err()); + assert!(Datetime::try_from(extreme).ok().is_none()); + } +} From a260e1be2eb9e812aea3bc387f15b4246823fca3 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 17 Jul 2026 11:34:18 -0400 Subject: [PATCH 2/4] wasi: rustfmt import order in filesystem.rs Signed-off-by: Sebastien Tardif --- crates/wasi/src/filesystem.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/wasi/src/filesystem.rs b/crates/wasi/src/filesystem.rs index 80411e4ee83f..e9ef5cf8ca64 100644 --- a/crates/wasi/src/filesystem.rs +++ b/crates/wasi/src/filesystem.rs @@ -1,5 +1,5 @@ use crate::clocks::Datetime; -use crate::runtime::{spawn_blocking, AbortOnDropJoinHandle}; +use crate::runtime::{AbortOnDropJoinHandle, spawn_blocking}; use cap_primitives::fs::{DirOptions, FollowSymlinks, Metadata, OpenOptions, SystemTimeSpec}; use std::collections::hash_map; use std::sync::Arc; From 4a8cd6917489594473de4ee8927de366584ec1dd Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 17 Jul 2026 13:23:13 -0400 Subject: [PATCH 3/4] wasi: address review on DescriptorStat timestamp conversion Inline try_from mapping, drop narrative comments, and keep a small unit test under mod test for SystemTime values outside Datetime's range. Guest set_times cannot construct those values because they already use Datetime; host Metadata is the overflow path. Signed-off-by: Sebastien Tardif --- crates/wasi/src/filesystem.rs | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/crates/wasi/src/filesystem.rs b/crates/wasi/src/filesystem.rs index e9ef5cf8ca64..4232700e1c86 100644 --- a/crates/wasi/src/filesystem.rs +++ b/crates/wasi/src/filesystem.rs @@ -310,13 +310,6 @@ impl DescriptorStat { /// Creates a `DescriptorStat` from a `Metadata` plus the hard link /// count. fn new(meta: &Metadata, link_count: u64) -> Self { - // Extreme SystemTime values can overflow Datetime's i64 seconds range. - // Treat those as missing timestamps instead of panicking the host - // when a guest stats a preopened path after set_times. - fn datetime_from(t: std::time::SystemTime) -> Option { - Datetime::try_from(t).ok() - } - Self { type_: meta.file_type().into(), link_count, @@ -324,15 +317,15 @@ impl DescriptorStat { data_access_timestamp: meta .accessed() .ok() - .and_then(|t| datetime_from(t.into_std())), + .and_then(|t| Datetime::try_from(t.into_std()).ok()), data_modification_timestamp: meta .modified() .ok() - .and_then(|t| datetime_from(t.into_std())), + .and_then(|t| Datetime::try_from(t.into_std()).ok()), status_change_timestamp: meta .created() .ok() - .and_then(|t| datetime_from(t.into_std())), + .and_then(|t| Datetime::try_from(t.into_std()).ok()), } } } @@ -1180,20 +1173,12 @@ impl WasiFilesystemCtxView<'_> { } #[cfg(test)] -mod datetime_from_tests { +mod test { use crate::clocks::Datetime; use std::time::{Duration, SystemTime}; - /// Extreme SystemTime values can fail `Datetime::try_from`. The old - /// `datetime_from` path used `.unwrap()`, which would panic the host - /// during `DescriptorStat` construction. Mapping with `.ok()` must - /// yield `None` instead. #[test] - fn extreme_system_time_is_none_not_panic() { - // A wall time far enough before the Unix epoch that the absolute - // second count does not fit in i64. Representable as SystemTime on - // common platforms, but `Datetime::try_from` returns Err. The old - // host path used `.unwrap()` and would panic during DescriptorStat. + fn datetime_try_from_extreme_system_time() { let extreme = SystemTime::UNIX_EPOCH .checked_sub(Duration::from_secs((i64::MAX as u64) + 1)) .expect("construct far-past SystemTime"); From d3181800c209cad6a343a8e156a34041152d04a5 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 18 Jul 2026 07:52:42 -0400 Subject: [PATCH 4/4] wasi: exercise extreme host mtime via guest filestat Prepare a host file with an extreme mtime and have the guest path_filestat_get and read it (p1 core module and p2 component runners). Also treat wall-clock conversions that fail for negative/out-of-range i64 timestamps as missing fields so the whole stat does not return OVERFLOW on platforms that clamp far- past times into i64. Signed-off-by: Sebastien Tardif --- .../src/bin/p1_stat_extreme_host_mtime.rs | 49 ++++++++++++++++++ crates/wasi/src/filesystem.rs | 40 ++++++++++++++- crates/wasi/src/p2/host/filesystem.rs | 10 ++-- crates/wasi/tests/all/p1.rs | 49 ++++++++++++++++++ crates/wasi/tests/all/p2/async_.rs | 48 +++++++++++++++++ crates/wasi/tests/all/p2/sync.rs | 51 +++++++++++++++++++ crates/wasi/tests/all/store.rs | 14 ++++- 7 files changed, 254 insertions(+), 7 deletions(-) create mode 100644 crates/test-programs/src/bin/p1_stat_extreme_host_mtime.rs diff --git a/crates/test-programs/src/bin/p1_stat_extreme_host_mtime.rs b/crates/test-programs/src/bin/p1_stat_extreme_host_mtime.rs new file mode 100644 index 000000000000..733353f7b649 --- /dev/null +++ b/crates/test-programs/src/bin/p1_stat_extreme_host_mtime.rs @@ -0,0 +1,49 @@ +#![expect(unsafe_op_in_unsafe_fn, reason = "old code, not worth updating yet")] + +use std::{env, process}; +use test_programs::preview1::open_scratch_directory; + +const FILENAME: &str = "extreme.dat"; +const EXPECTED: &[u8] = b"hello"; + +unsafe fn test_stat_extreme_host_mtime(dir_fd: wasip1::Fd) { + let st = wasip1::path_filestat_get(dir_fd, 0, FILENAME).expect("path_filestat_get"); + assert_eq!(st.size, EXPECTED.len() as u64, "size"); + let _ = st.mtim; + let _ = st.atim; + + let fd = + wasip1::path_open(dir_fd, 0, FILENAME, 0, wasip1::RIGHTS_FD_READ, 0, 0).expect("path_open"); + let mut buf = [0u8; 16]; + let nread = wasip1::fd_read( + fd, + &[wasip1::Iovec { + buf: buf.as_mut_ptr(), + buf_len: buf.len(), + }], + ) + .expect("fd_read"); + assert_eq!(&buf[..nread], EXPECTED, "contents"); + wasip1::fd_close(fd).expect("fd_close"); +} + +fn main() { + let mut args = env::args(); + let prog = args.next().unwrap(); + let arg = if let Some(arg) = args.next() { + arg + } else { + eprintln!("usage: {prog} "); + process::exit(1); + }; + + let dir_fd = match open_scratch_directory(&arg) { + Ok(dir_fd) => dir_fd, + Err(err) => { + eprintln!("{err}"); + process::exit(1); + } + }; + + unsafe { test_stat_extreme_host_mtime(dir_fd) } +} diff --git a/crates/wasi/src/filesystem.rs b/crates/wasi/src/filesystem.rs index 4232700e1c86..82fe383f468e 100644 --- a/crates/wasi/src/filesystem.rs +++ b/crates/wasi/src/filesystem.rs @@ -1174,15 +1174,51 @@ impl WasiFilesystemCtxView<'_> { #[cfg(test)] mod test { + use super::*; use crate::clocks::Datetime; + use std::io::{Read, Write}; use std::time::{Duration, SystemTime}; #[test] fn datetime_try_from_extreme_system_time() { let extreme = SystemTime::UNIX_EPOCH .checked_sub(Duration::from_secs((i64::MAX as u64) + 1)) - .expect("construct far-past SystemTime"); - assert!(Datetime::try_from(extreme).is_err()); + .unwrap(); assert!(Datetime::try_from(extreme).ok().is_none()); } + + #[test] + fn host_prepared_extreme_mtime_stats_and_reads() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("extreme.dat"); + std::fs::File::create(&path) + .unwrap() + .write_all(b"hello") + .unwrap(); + + let extreme = SystemTime::UNIX_EPOCH + .checked_sub(Duration::from_secs((i64::MAX as u64) + 1)) + .unwrap(); + let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap(); + let times = std::fs::FileTimes::new() + .set_modified(extreme) + .set_accessed(extreme); + // Platforms may reject or clamp extreme times. + let _ = f.set_times(times); + drop(f); + + let f = std::fs::File::open(&path).unwrap(); + let stat = sys::stat(&f).expect("stat"); + let mut buf = Vec::new(); + std::fs::File::open(&path) + .unwrap() + .read_to_end(&mut buf) + .unwrap(); + assert_eq!(buf, b"hello"); + let _ = ( + stat.data_access_timestamp, + stat.data_modification_timestamp, + stat.status_change_timestamp, + ); + } } diff --git a/crates/wasi/src/p2/host/filesystem.rs b/crates/wasi/src/p2/host/filesystem.rs index a81b6622e545..3a668bb33b7b 100644 --- a/crates/wasi/src/p2/host/filesystem.rs +++ b/crates/wasi/src/p2/host/filesystem.rs @@ -590,15 +590,17 @@ impl TryFrom for types::DescriptorStat { status_change_timestamp, }: crate::filesystem::DescriptorStat, ) -> Result { + // Internal timestamps use i64 seconds; wasi:clocks/wall-clock uses u64 + // (non-negative). Times outside that range become missing rather than + // failing the whole stat (e.g. host-clamped far-past mtimes on macOS). Ok(Self { type_: type_.into(), link_count, size, - data_access_timestamp: data_access_timestamp.map(|t| t.try_into()).transpose()?, + data_access_timestamp: data_access_timestamp.and_then(|t| t.try_into().ok()), data_modification_timestamp: data_modification_timestamp - .map(|t| t.try_into()) - .transpose()?, - status_change_timestamp: status_change_timestamp.map(|t| t.try_into()).transpose()?, + .and_then(|t| t.try_into().ok()), + status_change_timestamp: status_change_timestamp.and_then(|t| t.try_into().ok()), }) } } diff --git a/crates/wasi/tests/all/p1.rs b/crates/wasi/tests/all/p1.rs index cd7077afe8bf..182d7a8b1d64 100644 --- a/crates/wasi/tests/all/p1.rs +++ b/crates/wasi/tests/all/p1.rs @@ -25,6 +25,29 @@ async fn run(path: &str, with_builder: impl FnOnce(&mut WasiCtxBuilder)) -> Resu Ok(()) } +async fn run_with_workspace_setup( + path: &str, + setup: impl FnOnce(&Path) -> Result<()>, + with_builder: impl FnOnce(&mut WasiCtxBuilder), +) -> Result<()> { + let path = Path::new(path); + let name = path.file_stem().unwrap().to_str().unwrap(); + let engine = test_programs_artifacts::engine(|_config| {}); + let mut linker = Linker::>::new(&engine); + add_to_linker_async(&mut linker, |t| &mut t.wasi)?; + + let module = Module::from_file(&engine, path)?; + let (mut store, _td) = Ctx::new_with_workspace_setup(&engine, name, setup, |builder| { + with_builder(builder); + builder.build_p1() + })?; + store.data_mut().wasi.ctx().table.set_max_capacity(1000); + let instance = linker.instantiate_async(&mut store, &module).await?; + let start = instance.get_typed_func::<(), ()>(&mut store, "_start")?; + start.call_async(&mut store, ()).await?; + Ok(()) +} + foreach_p1!(assert_test_exists); // Below here is mechanical: there should be one test for every binary in @@ -69,6 +92,32 @@ async fn p1_fd_filestat_get() { async fn p1_fd_filestat_set() { run(P1_FD_FILESTAT_SET, |_| {}).await.unwrap() } + +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn p1_stat_extreme_host_mtime() { + use std::fs::{File, FileTimes}; + use std::io::Write; + use std::time::{Duration, SystemTime}; + + run_with_workspace_setup( + P1_STAT_EXTREME_HOST_MTIME, + |dir| { + let path = dir.join("extreme.dat"); + File::create(&path)?.write_all(b"hello")?; + let extreme = SystemTime::UNIX_EPOCH + .checked_sub(Duration::from_secs((i64::MAX as u64) + 1)) + .expect("construct extreme SystemTime"); + let f = File::options().write(true).open(&path)?; + let times = FileTimes::new().set_modified(extreme).set_accessed(extreme); + // Platforms may reject or clamp extreme times. + let _ = f.set_times(times); + Ok(()) + }, + |_| {}, + ) + .await + .unwrap() +} #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn p1_fd_flags_set() { run(P1_FD_FLAGS_SET, |_| {}).await.unwrap() diff --git a/crates/wasi/tests/all/p2/async_.rs b/crates/wasi/tests/all/p2/async_.rs index fe43baa0ccd6..58e91cfd1e24 100644 --- a/crates/wasi/tests/all/p2/async_.rs +++ b/crates/wasi/tests/all/p2/async_.rs @@ -27,6 +27,30 @@ async fn run(path: &str, with_builder: impl FnOnce(&mut WasiCtxBuilder)) -> Resu .map_err(|()| wasmtime::format_err!("run returned a failure")) } +async fn run_with_workspace_setup( + path: &str, + setup: impl FnOnce(&Path) -> Result<()>, + with_builder: impl FnOnce(&mut WasiCtxBuilder), +) -> Result<()> { + let path = Path::new(path); + let name = path.file_stem().unwrap().to_str().unwrap(); + let engine = test_programs_artifacts::engine(|_config| {}); + let mut linker = Linker::new(&engine); + add_to_linker_async(&mut linker)?; + + let (mut store, _td) = Ctx::new_with_workspace_setup(&engine, name, setup, |builder| { + with_builder(builder); + MyWasiCtx::new(builder.build()) + })?; + let component = Component::from_file(&engine, path)?; + let command = Command::instantiate_async(&mut store, &component, &linker).await?; + command + .wasi_cli_run() + .call_run(&mut store) + .await? + .map_err(|()| wasmtime::format_err!("run returned a failure")) +} + foreach_p1!(assert_test_exists); foreach_p2!(assert_test_exists); @@ -73,6 +97,30 @@ async fn p1_fd_filestat_set() { run(P1_FD_FILESTAT_SET_COMPONENT, |_| {}).await.unwrap() } #[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn p1_stat_extreme_host_mtime() { + use std::fs::{File, FileTimes}; + use std::io::Write; + use std::time::{Duration, SystemTime}; + + run_with_workspace_setup( + P1_STAT_EXTREME_HOST_MTIME_COMPONENT, + |dir| { + let path = dir.join("extreme.dat"); + File::create(&path)?.write_all(b"hello")?; + let extreme = SystemTime::UNIX_EPOCH + .checked_sub(Duration::from_secs((i64::MAX as u64) + 1)) + .expect("construct extreme SystemTime"); + let f = File::options().write(true).open(&path)?; + let times = FileTimes::new().set_modified(extreme).set_accessed(extreme); + let _ = f.set_times(times); + Ok(()) + }, + |_| {}, + ) + .await + .unwrap() +} +#[test_log::test(tokio::test(flavor = "multi_thread"))] async fn p1_fd_flags_set() { run(P1_FD_FLAGS_SET_COMPONENT, |_| {}).await.unwrap() } diff --git a/crates/wasi/tests/all/p2/sync.rs b/crates/wasi/tests/all/p2/sync.rs index 0c1aaf6fcd44..89871a98b0bf 100644 --- a/crates/wasi/tests/all/p2/sync.rs +++ b/crates/wasi/tests/all/p2/sync.rs @@ -31,6 +31,34 @@ fn run(path: &str, with_builder: impl Fn(&mut WasiCtxBuilder)) -> Result<()> { Ok(()) } +fn run_with_workspace_setup( + path: &str, + setup: impl Fn(&Path) -> Result<()>, + with_builder: impl Fn(&mut WasiCtxBuilder), +) -> Result<()> { + let path = Path::new(path); + let name = path.file_stem().unwrap().to_str().unwrap(); + let engine = test_programs_artifacts::engine(|_| {}); + let mut linker = Linker::new(&engine); + add_to_linker_sync(&mut linker)?; + + let component = Component::from_file(&engine, path)?; + + for blocking in [false, true] { + let (mut store, _td) = Ctx::new_with_workspace_setup(&engine, name, &setup, |builder| { + with_builder(builder); + builder.allow_blocking_current_thread(blocking); + MyWasiCtx::new(builder.build()) + })?; + let command = Command::instantiate(&mut store, &component, &linker)?; + command + .wasi_cli_run() + .call_run(&mut store)? + .map_err(|()| wasmtime::format_err!("run returned a failure"))?; + } + Ok(()) +} + foreach_p1!(assert_test_exists); foreach_p2!(assert_test_exists); @@ -77,6 +105,29 @@ fn p1_fd_filestat_set() { run(P1_FD_FILESTAT_SET_COMPONENT, |_| {}).unwrap() } #[test_log::test] +fn p1_stat_extreme_host_mtime() { + use std::fs::{File, FileTimes}; + use std::io::Write; + use std::time::{Duration, SystemTime}; + + run_with_workspace_setup( + P1_STAT_EXTREME_HOST_MTIME_COMPONENT, + |dir| { + let path = dir.join("extreme.dat"); + File::create(&path)?.write_all(b"hello")?; + let extreme = SystemTime::UNIX_EPOCH + .checked_sub(Duration::from_secs((i64::MAX as u64) + 1)) + .expect("construct extreme SystemTime"); + let f = File::options().write(true).open(&path)?; + let times = FileTimes::new().set_modified(extreme).set_accessed(extreme); + let _ = f.set_times(times); + Ok(()) + }, + |_| {}, + ) + .unwrap() +} +#[test_log::test] fn p1_fd_flags_set() { run(P1_FD_FLAGS_SET_COMPONENT, |_| {}).unwrap() } diff --git a/crates/wasi/tests/all/store.rs b/crates/wasi/tests/all/store.rs index 50f9fbf9481c..12c5f44de319 100644 --- a/crates/wasi/tests/all/store.rs +++ b/crates/wasi/tests/all/store.rs @@ -23,11 +23,23 @@ impl Ctx { engine: &Engine, name: &str, configure: impl FnOnce(&mut WasiCtxBuilder) -> T, + ) -> Result<(Store>, TempDir)> { + Self::new_with_workspace_setup(engine, name, |_| Ok(()), configure) + } + + /// Like [`Self::new`], but allows seeding the preopened scratch directory + /// before the guest runs (for host-prepared filesystem fixtures). + pub fn new_with_workspace_setup( + engine: &Engine, + name: &str, + setup: impl FnOnce(&std::path::Path) -> Result<()>, + configure: impl FnOnce(&mut WasiCtxBuilder) -> T, ) -> Result<(Store>, TempDir)> { const MAX_OUTPUT_SIZE: usize = 10 << 20; let stdout = MemoryOutputPipe::new(MAX_OUTPUT_SIZE); let stderr = MemoryOutputPipe::new(MAX_OUTPUT_SIZE); let workspace = prepare_workspace(name)?; + setup(workspace.path())?; // Create our wasi context. let mut builder = WasiCtxBuilder::new(); @@ -54,7 +66,7 @@ impl Ctx { stdout, }; - Ok((Store::new(&engine, ctx), workspace)) + Ok((Store::new(engine, ctx), workspace)) } }