From dd67500bd7b7dca77845d160798188d8940e120f Mon Sep 17 00:00:00 2001 From: Preetham Mysore Date: Sat, 4 Jul 2026 23:33:59 -0400 Subject: [PATCH 1/2] fix(wasm): route clock reads through web-time so bashkit runs on wasm32-unknown-unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit std::time::{Instant, SystemTime} panic at runtime on wasm32-unknown-unknown ("time not implemented on this platform"): the target has no OS clock and std provides no extension point. All wall-clock reads in the bashkit crate now go through a new crate::time_compat module — a re-export of web-time (Performance.now()/Date.now() via web-sys) on wasm32, and of std::time everywhere else with zero overhead — plus two chrono bridge helpers, since chrono only implements From, not web-time's. Guards so this can't silently rot like examples/browser did: - CI job checking bashkit for wasm32-unknown-unknown with -D warnings - source-scan test keeping std::time clock types out of src/ (escape hatch: `// std-time-ok: `) Also gates Interpreter::clear_cancelled_execution_state to non-wasm: its only caller is the native-only tokio timeout recovery path, so it was dead code (a warning) on wasm targets. --- .github/workflows/ci.yml | 24 ++++++ Cargo.lock | 11 +++ Cargo.toml | 7 ++ crates/bashkit/Cargo.toml | 6 ++ crates/bashkit/src/builtins/archive.rs | 4 +- crates/bashkit/src/builtins/curl.rs | 6 +- crates/bashkit/src/builtins/date.rs | 2 +- crates/bashkit/src/builtins/fileops.rs | 4 +- crates/bashkit/src/builtins/inspect.rs | 2 +- crates/bashkit/src/builtins/ls/find.rs | 2 +- crates/bashkit/src/builtins/ls/list.rs | 2 +- crates/bashkit/src/builtins/mod.rs | 4 +- crates/bashkit/src/builtins/python.rs | 5 +- crates/bashkit/src/builtins/rg/mod.rs | 4 +- crates/bashkit/src/builtins/shuf.rs | 4 +- crates/bashkit/src/builtins/sleep.rs | 4 +- crates/bashkit/src/builtins/sqlite/engine.rs | 2 +- crates/bashkit/src/fs/backend.rs | 2 +- crates/bashkit/src/fs/memory.rs | 2 +- crates/bashkit/src/fs/mod.rs | 2 +- crates/bashkit/src/fs/mountable.rs | 6 +- crates/bashkit/src/fs/overlay.rs | 2 +- crates/bashkit/src/fs/posix.rs | 2 +- crates/bashkit/src/fs/readonly.rs | 2 +- crates/bashkit/src/fs/realfs.rs | 2 +- crates/bashkit/src/fs/traits.rs | 2 +- crates/bashkit/src/interop/fs.rs | 3 +- crates/bashkit/src/interpreter/mod.rs | 11 ++- crates/bashkit/src/lib.rs | 3 +- crates/bashkit/src/network/bot_auth.rs | 2 +- crates/bashkit/src/parser/mod.rs | 3 +- crates/bashkit/src/scripted_tool/execute.rs | 6 +- crates/bashkit/src/time_compat.rs | 48 +++++++++++ crates/bashkit/src/tool.rs | 2 +- crates/bashkit/tests/integration/main.rs | 1 + .../integration/time_compat_scan_tests.rs | 82 +++++++++++++++++++ supply-chain/config.toml | 4 + 37 files changed, 236 insertions(+), 44 deletions(-) create mode 100644 crates/bashkit/src/time_compat.rs create mode 100644 crates/bashkit/tests/integration/time_compat_scan_tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4f151518..5bdf5a81d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,30 @@ jobs: env: RUSTDOCFLAGS: "-D warnings" + wasm: + # std::time panics on wasm32-unknown-unknown, so bashkit routes clock reads + # through src/time_compat.rs (web-time on wasm32). This job keeps the crate + # compiling for browser/wasm-bindgen consumers; the feature list is the + # supported wasm32-unknown-unknown surface (python is excluded: monty pulls + # getrandom 0.3 without its wasm_js feature). + name: WASM (wasm32-unknown-unknown) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - name: Check bashkit for wasm32-unknown-unknown + run: cargo check -p bashkit --target wasm32-unknown-unknown --features scripted_tool,jq + env: + # getrandom needs an explicit JS backend opt-in on this target. + RUSTFLAGS: --cfg getrandom_backend="wasm_js" -D warnings + audit: name: Audit runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 5bf41c0a2..085812ba9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -394,6 +394,7 @@ dependencies = [ "turso_core", "unit-prefix", "url", + "web-time", "zapcode-core", "zeroize", ] @@ -5847,6 +5848,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index 01f8637f1..2e6959c10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,13 @@ zeroize = "1" # CSPRNG primitive and is already transitively in the dep tree via reqwest/rustls. getrandom = { version = "0.4", features = ["wasm_js"] } +# Drop-in std::time::{Instant,SystemTime} replacement for wasm32-unknown-unknown, +# where std::time is a hard panic ("time not implemented on this platform" — no +# extension point, see library/std/src/sys/time/unsupported.rs). Backed by +# Performance.now()/Date.now() via web-sys on wasm32; re-exports std::time +# unchanged everywhere else. See crates/bashkit/src/time_compat.rs. +web-time = "1" + # CLI # Intentionally NOT enabling clap's `env` feature: it makes `Arg::env(...)` # read defaults from `std::env`, which would tunnel host process state into diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index d08b0fdce..b701d61f7 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -262,3 +262,9 @@ required-features = ["sqlite"] # Additional tokio features needed only on native (not WASM) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1", features = ["rt-multi-thread", "fs"] } + +# std::time::{Instant,SystemTime} panic on wasm32-unknown-unknown; see +# time_compat.rs. Only pulled in for wasm32 — everywhere else time_compat +# re-exports std::time directly with zero overhead. +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-time = { workspace = true } diff --git a/crates/bashkit/src/builtins/archive.rs b/crates/bashkit/src/builtins/archive.rs index e020287ed..2e47a0249 100644 --- a/crates/bashkit/src/builtins/archive.rs +++ b/crates/bashkit/src/builtins/archive.rs @@ -332,7 +332,7 @@ async fn add_file_to_tar( // Mtime (12 bytes, octal) let mtime = metadata .modified - .duration_since(std::time::UNIX_EPOCH) + .duration_since(crate::time_compat::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); write_octal(&mut header[136..148], mtime, 11); @@ -403,7 +403,7 @@ fn add_directory_to_tar<'a>( // Mtime let mtime = metadata .modified - .duration_since(std::time::UNIX_EPOCH) + .duration_since(crate::time_compat::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); write_octal(&mut header[136..148], mtime, 11); diff --git a/crates/bashkit/src/builtins/curl.rs b/crates/bashkit/src/builtins/curl.rs index 3cb919f35..8c972f6c3 100644 --- a/crates/bashkit/src/builtins/curl.rs +++ b/crates/bashkit/src/builtins/curl.rs @@ -513,8 +513,8 @@ async fn execute_curl_request( let multipart_body: Option> = if !form_fields.is_empty() { let boundary = format!( "----bashkit{:016x}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + crate::time_compat::SystemTime::now() + .duration_since(crate::time_compat::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0) ); @@ -1758,7 +1758,7 @@ mod tests { async fn set_modified_time( &self, path: &std::path::Path, - time: std::time::SystemTime, + time: crate::time_compat::SystemTime, ) -> Result<()> { self.inner.set_modified_time(path, time).await } diff --git a/crates/bashkit/src/builtins/date.rs b/crates/bashkit/src/builtins/date.rs index 8a9b07516..209ada00e 100644 --- a/crates/bashkit/src/builtins/date.rs +++ b/crates/bashkit/src/builtins/date.rs @@ -451,7 +451,7 @@ impl Builtin for Date { let path = resolve_path(ctx.cwd, file); match ctx.fs.stat(&path).await { Ok(meta) => { - dt_utc = meta.modified.into(); + dt_utc = crate::time_compat::to_chrono_utc(meta.modified); epoch_input = false; } Err(_) => { diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index 59eb6d813..0f677c439 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -2,10 +2,10 @@ // Decision: touch delegates mtime changes to the filesystem layer so `touch` // and `touch -t` stay consistent across in-memory, overlay, and realfs backends. +use crate::time_compat::SystemTime; use async_trait::async_trait; use chrono::{Datelike, Local, LocalResult, NaiveDate, TimeZone}; use std::path::Path; -use std::time::SystemTime; use super::limits::MKTEMP_MAX_ATTEMPTS; use super::{Builtin, Context, resolve_path}; @@ -376,7 +376,7 @@ fn parse_touch_timestamp(raw: &str) -> std::result::Result { LocalResult::None => return Err(format!("touch: invalid date format '{}'\n", raw)), }; - Ok(local.into()) + Ok(crate::time_compat::from_chrono(local)) } #[async_trait] diff --git a/crates/bashkit/src/builtins/inspect.rs b/crates/bashkit/src/builtins/inspect.rs index 9b871a76c..6afb20726 100644 --- a/crates/bashkit/src/builtins/inspect.rs +++ b/crates/bashkit/src/builtins/inspect.rs @@ -417,7 +417,7 @@ fn format_permissions(metadata: &crate::fs::Metadata) -> String { fn default_stat_format(name: &str, metadata: &crate::fs::Metadata) -> String { let modified = metadata .modified - .duration_since(std::time::UNIX_EPOCH) + .duration_since(crate::time_compat::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); diff --git a/crates/bashkit/src/builtins/ls/find.rs b/crates/bashkit/src/builtins/ls/find.rs index 3d6f980cc..c472d7ca7 100644 --- a/crates/bashkit/src/builtins/ls/find.rs +++ b/crates/bashkit/src/builtins/ls/find.rs @@ -674,7 +674,7 @@ fn find_printf_format( chars.next(); let secs = metadata .modified - .duration_since(std::time::UNIX_EPOCH) + .duration_since(crate::time_compat::UNIX_EPOCH) .ok() .map(|d| d.as_secs()) .unwrap_or(0); diff --git a/crates/bashkit/src/builtins/ls/list.rs b/crates/bashkit/src/builtins/ls/list.rs index 00a78570d..cfb5830ac 100644 --- a/crates/bashkit/src/builtins/ls/list.rs +++ b/crates/bashkit/src/builtins/ls/list.rs @@ -449,7 +449,7 @@ pub(super) fn format_long_entry(name: &str, metadata: &crate::fs::Metadata, huma // Format modified time let modified = metadata .modified - .duration_since(std::time::UNIX_EPOCH) + .duration_since(crate::time_compat::UNIX_EPOCH) .map(|d| { let secs = d.as_secs(); // Simple date formatting: YYYY-MM-DD HH:MM diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index c9cf2ea0f..8ea234eb2 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -400,7 +400,7 @@ pub struct ExecutionExtensions { #[derive(Debug, Clone)] pub(crate) struct ExecutionDeadline { #[allow(dead_code)] - started_at: std::time::Instant, + started_at: crate::time_compat::Instant, timeout: std::time::Duration, } @@ -408,7 +408,7 @@ impl ExecutionDeadline { /// Create a deadline anchored at the start of `Bash::exec*`. pub(crate) fn new(timeout: std::time::Duration) -> Self { Self { - started_at: std::time::Instant::now(), + started_at: crate::time_compat::Instant::now(), timeout, } } diff --git a/crates/bashkit/src/builtins/python.rs b/crates/bashkit/src/builtins/python.rs index c778d8b3e..0031f7da7 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -18,6 +18,7 @@ //! //! Supports: `python -c "code"`, `python script.py`, stdin piping. +use crate::time_compat::Instant; use async_trait::async_trait; use chrono::{Datelike, Timelike}; use monty::{ @@ -30,7 +31,7 @@ use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; use super::{Builtin, Context, ExecutionDeadline, RuntimeLimits, resolve_path}; use crate::error::Result; @@ -833,7 +834,7 @@ async fn handle_os_call( Ok(meta) => { let mtime = meta .modified - .duration_since(std::time::UNIX_EPOCH) + .duration_since(crate::time_compat::UNIX_EPOCH) .map(|d| d.as_secs_f64()) .unwrap_or(0.0); let stat_obj = match meta.file_type { diff --git a/crates/bashkit/src/builtins/rg/mod.rs b/crates/bashkit/src/builtins/rg/mod.rs index 907e0c003..765ed043e 100644 --- a/crates/bashkit/src/builtins/rg/mod.rs +++ b/crates/bashkit/src/builtins/rg/mod.rs @@ -7138,7 +7138,7 @@ mod tests { fs_trait.write_file(p, content).await.unwrap(); } for (path, secs) in mtimes { - let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(*secs); + let time = crate::time_compat::UNIX_EPOCH + std::time::Duration::from_secs(*secs); fs_trait .set_modified_time(Path::new(path), time) .await @@ -12593,7 +12593,7 @@ mod tests { } for (path, secs) in case.mtimes { let host_path = tempdir.path().join(path.trim_start_matches('/')); - let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(*secs); + let time = crate::time_compat::UNIX_EPOCH + std::time::Duration::from_secs(*secs); std::fs::File::open(host_path) .expect("open timed rg fixture file") .set_modified(time) diff --git a/crates/bashkit/src/builtins/shuf.rs b/crates/bashkit/src/builtins/shuf.rs index ad3959d10..5762a8502 100644 --- a/crates/bashkit/src/builtins/shuf.rs +++ b/crates/bashkit/src/builtins/shuf.rs @@ -364,8 +364,8 @@ struct SmallRng { impl SmallRng { fn seed_from_now() -> Self { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let nanos = crate::time_compat::SystemTime::now() + .duration_since(crate::time_compat::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0x123_4567_89AB_CDEF); // SystemTime::now can return a tiny duration in tight loops; XOR diff --git a/crates/bashkit/src/builtins/sleep.rs b/crates/bashkit/src/builtins/sleep.rs index fa03a2be7..6630660ab 100644 --- a/crates/bashkit/src/builtins/sleep.rs +++ b/crates/bashkit/src/builtins/sleep.rs @@ -95,7 +95,7 @@ mod tests { #[tokio::test] async fn test_sleep_zero() { - let start = std::time::Instant::now(); + let start = crate::time_compat::Instant::now(); let result = run_sleep(&["0"]).await; let elapsed = start.elapsed(); @@ -105,7 +105,7 @@ mod tests { #[tokio::test] async fn test_sleep_fractional() { - let start = std::time::Instant::now(); + let start = crate::time_compat::Instant::now(); let result = run_sleep(&["0.1"]).await; let elapsed = start.elapsed(); diff --git a/crates/bashkit/src/builtins/sqlite/engine.rs b/crates/bashkit/src/builtins/sqlite/engine.rs index 32882bcc3..d95ad989c 100644 --- a/crates/bashkit/src/builtins/sqlite/engine.rs +++ b/crates/bashkit/src/builtins/sqlite/engine.rs @@ -15,9 +15,9 @@ //! Both expose the same query API. The builtin layer above is agnostic to //! which backend is active. +use crate::time_compat::Instant; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Instant; use turso_core::{Connection, Database, IO, MemoryIO, Numeric, OpenFlags, StepResult, Value}; diff --git a/crates/bashkit/src/fs/backend.rs b/crates/bashkit/src/fs/backend.rs index 4d8c25782..6ca95b706 100644 --- a/crates/bashkit/src/fs/backend.rs +++ b/crates/bashkit/src/fs/backend.rs @@ -92,9 +92,9 @@ //! //! See `examples/custom_backend.rs` for a complete working example. +use crate::time_compat::SystemTime; use async_trait::async_trait; use std::path::{Path, PathBuf}; -use std::time::SystemTime; use super::limits::{FsLimits, FsUsage}; use super::traits::{DirEntry, Metadata}; diff --git a/crates/bashkit/src/fs/memory.rs b/crates/bashkit/src/fs/memory.rs index c650e762e..9121edd44 100644 --- a/crates/bashkit/src/fs/memory.rs +++ b/crates/bashkit/src/fs/memory.rs @@ -38,12 +38,12 @@ // while holding lock). This is intentional - corrupted state should not propagate. #![allow(clippy::unwrap_used)] +use crate::time_compat::SystemTime; use async_trait::async_trait; use std::collections::HashMap; use std::io::{Error as IoError, ErrorKind}; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; -use std::time::SystemTime; use super::limits::{FsLimits, FsUsage}; use super::traits::{DirEntry, FileSystem, FileSystemExt, FileType, Metadata}; diff --git a/crates/bashkit/src/fs/mod.rs b/crates/bashkit/src/fs/mod.rs index 6c6578855..9abcfea6c 100644 --- a/crates/bashkit/src/fs/mod.rs +++ b/crates/bashkit/src/fs/mod.rs @@ -424,9 +424,9 @@ pub use search::{ pub use traits::{DirEntry, FileSystem, FileSystemExt, FileType, Metadata, fs_errors}; use crate::error::Result; +use crate::time_compat::SystemTime; use std::io::{Error as IoError, ErrorKind}; use std::path::{Path, PathBuf}; -use std::time::SystemTime; /// Filesystem implementation for logic-only shells. /// diff --git a/crates/bashkit/src/fs/mountable.rs b/crates/bashkit/src/fs/mountable.rs index 4c1bd3c55..8db564c1a 100644 --- a/crates/bashkit/src/fs/mountable.rs +++ b/crates/bashkit/src/fs/mountable.rs @@ -7,12 +7,12 @@ // while holding lock). This is intentional - corrupted state should not propagate. #![allow(clippy::unwrap_used)] +use crate::time_compat::SystemTime; use async_trait::async_trait; use std::collections::BTreeMap; use std::io::Error as IoError; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; -use std::time::SystemTime; use super::limits::{FsLimits, FsUsage}; use super::traits::{DirEntry, FileSystem, FileSystemExt, FileType, Metadata}; @@ -420,8 +420,8 @@ impl FileSystem for MountableFs { file_type: FileType::Directory, size: 0, mode: 0o755, - modified: std::time::SystemTime::now(), - created: std::time::SystemTime::now(), + modified: crate::time_compat::SystemTime::now(), + created: crate::time_compat::SystemTime::now(), }, }); } diff --git a/crates/bashkit/src/fs/overlay.rs b/crates/bashkit/src/fs/overlay.rs index 811a7e87d..d6071b4d7 100644 --- a/crates/bashkit/src/fs/overlay.rs +++ b/crates/bashkit/src/fs/overlay.rs @@ -31,12 +31,12 @@ // while holding lock). This is intentional - corrupted state should not propagate. #![allow(clippy::unwrap_used)] +use crate::time_compat::SystemTime; use async_trait::async_trait; use std::collections::HashSet; use std::io::{Error as IoError, ErrorKind}; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; -use std::time::SystemTime; use super::limits::{FsLimits, FsUsage}; use super::memory::InMemoryFs; diff --git a/crates/bashkit/src/fs/posix.rs b/crates/bashkit/src/fs/posix.rs index 45c7c96a6..503e273ff 100644 --- a/crates/bashkit/src/fs/posix.rs +++ b/crates/bashkit/src/fs/posix.rs @@ -46,11 +46,11 @@ //! //! See [`FsBackend`](super::FsBackend) for how to implement a backend. +use crate::time_compat::SystemTime; use async_trait::async_trait; use std::io::Error as IoError; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::SystemTime; use super::backend::FsBackend; use super::limits::{FsLimits, FsUsage}; diff --git a/crates/bashkit/src/fs/readonly.rs b/crates/bashkit/src/fs/readonly.rs index 4658344cd..ce9dc4822 100644 --- a/crates/bashkit/src/fs/readonly.rs +++ b/crates/bashkit/src/fs/readonly.rs @@ -4,11 +4,11 @@ //! embedder wants a session that can inspect data but cannot persist or stage //! any filesystem changes, including copies into the in-memory VFS. +use crate::time_compat::SystemTime; use async_trait::async_trait; use std::io::{Error as IoError, ErrorKind}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::SystemTime; use super::limits::{FsLimits, FsUsage}; use super::traits::{DirEntry, FileSystem, FileSystemExt, Metadata}; diff --git a/crates/bashkit/src/fs/realfs.rs b/crates/bashkit/src/fs/realfs.rs index 66a4b3853..721c8152e 100644 --- a/crates/bashkit/src/fs/realfs.rs +++ b/crates/bashkit/src/fs/realfs.rs @@ -65,10 +65,10 @@ //! bashkit --mount-rw /path/to/out:/mnt/out -c 'echo hi > /mnt/out/result.txt' //! ``` +use crate::time_compat::SystemTime; use async_trait::async_trait; use std::io::{Error as IoError, ErrorKind}; use std::path::{Path, PathBuf}; -use std::time::SystemTime; use super::backend::FsBackend; use super::limits::{FsLimits, FsUsage}; diff --git a/crates/bashkit/src/fs/traits.rs b/crates/bashkit/src/fs/traits.rs index 25275af2d..dcd82fbda 100644 --- a/crates/bashkit/src/fs/traits.rs +++ b/crates/bashkit/src/fs/traits.rs @@ -40,10 +40,10 @@ //! } //! ``` +use crate::time_compat::SystemTime; use async_trait::async_trait; use std::io::{Error as IoError, ErrorKind}; use std::path::Path; -use std::time::SystemTime; use super::limits::{FsLimits, FsUsage}; use crate::error::Result; diff --git a/crates/bashkit/src/interop/fs.rs b/crates/bashkit/src/interop/fs.rs index 241447e39..4334afcea 100644 --- a/crates/bashkit/src/interop/fs.rs +++ b/crates/bashkit/src/interop/fs.rs @@ -1,6 +1,7 @@ // Decision: cross-addon filesystem interop uses only a versioned repr(C) // handle + vtable. Rust trait objects stay inside the exporting addon. +use crate::time_compat::{SystemTime, UNIX_EPOCH}; use crate::{ DirEntry, Error as BashError, FileSystem, FileSystemExt, FileType, Metadata, Result as BashResult, async_trait, @@ -14,7 +15,7 @@ use std::slice; use std::str; use std::sync::Arc; use std::sync::mpsc::sync_channel; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::Duration; use tokio::runtime::{Builder, Runtime}; pub const BASHKIT_FS_ABI_VERSION_V1: u32 = 1; diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 72e80a1df..e5f3c9905 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1883,6 +1883,10 @@ impl Interpreter { self.arrays_mut().remove("BASH_SOURCE"); } + // Only called from the tokio-timeout recovery path in Bash::exec, which is + // native-only (wasm has no reliable timer driver, so no timeout to recover + // from — see TM-DOS-057 in lib.rs). + #[cfg(not(target_family = "wasm"))] pub(crate) fn clear_cancelled_execution_state(&mut self) { self.reconcile_cancelled_execution_state(0, 0, 0, None); } @@ -3971,7 +3975,7 @@ impl Interpreter { /// User and system CPU time are always reported as 0. /// This is a documented incompatibility with bash. async fn execute_time(&mut self, time_cmd: &TimeCommand) -> Result { - use std::time::Instant; + use crate::time_compat::Instant; let start = Instant::now(); @@ -5601,7 +5605,7 @@ impl Interpreter { let trace_start = if self.trace.mode() != crate::trace::TraceMode::Off { self.trace .command_start(name, &args, self.cwd.to_string_lossy().as_ref()); - Some(std::time::Instant::now()) + Some(crate::time_compat::Instant::now()) } else { None }; @@ -10931,7 +10935,8 @@ mod tests { #[tokio::test] async fn test_extglob_no_hang() { - use std::time::{Duration, Instant}; + use crate::time_compat::Instant; + use std::time::Duration; let start = Instant::now(); let result = run_script( r#"shopt -s extglob; [[ "aaaaaaaaaaaa" == +(a|aa) ]] && echo yes || echo no"#, diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index cfd07aafe..a66b6d5cf 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -428,6 +428,7 @@ mod snapshot; /// invariants enforced (TM-INF-013, TM-INF-016, TM-INF-022). #[doc(hidden)] pub mod testing; +mod time_compat; /// Tool contract for LLM integration. /// Requires the `bash_tool` feature (enabled by default). #[cfg(feature = "bash_tool")] @@ -954,7 +955,7 @@ impl Bash { // Load persisted history on first exec (no-op if already loaded) self.interpreter.load_history().await; - let exec_start = std::time::Instant::now(); + let exec_start = crate::time_compat::Instant::now(); // THREAT[TM-DOS-057]: Wrap execution with timeout to prevent sleep/blocking bypass. // Only the native path arms the tokio timeout; wasm has no reliable timer driver. #[cfg(not(target_family = "wasm"))] diff --git a/crates/bashkit/src/network/bot_auth.rs b/crates/bashkit/src/network/bot_auth.rs index 8cee8ce00..71c4a276f 100644 --- a/crates/bashkit/src/network/bot_auth.rs +++ b/crates/bashkit/src/network/bot_auth.rs @@ -18,11 +18,11 @@ //! .with_validity_secs(300); //! ``` +use crate::time_compat::{SystemTime, UNIX_EPOCH}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; use rand::Rng; use sha2::{Digest, Sha256}; -use std::time::{SystemTime, UNIX_EPOCH}; use zeroize::Zeroize; /// Configuration for Web Bot Authentication. diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 40b6c1010..53bd5e886 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -26,7 +26,8 @@ pub use span::{Position, Span}; use crate::error::{Error, Result}; use crate::limits::LimitExceeded; -use std::time::{Duration, Instant}; +use crate::time_compat::Instant; +use std::time::Duration; /// Default maximum AST depth (matches ExecutionLimits default) const DEFAULT_MAX_AST_DEPTH: usize = 100; diff --git a/crates/bashkit/src/scripted_tool/execute.rs b/crates/bashkit/src/scripted_tool/execute.rs index 6477cbb5e..1fdad8853 100644 --- a/crates/bashkit/src/scripted_tool/execute.rs +++ b/crates/bashkit/src/scripted_tool/execute.rs @@ -226,7 +226,7 @@ impl Tool for ScriptedTool { let req = tool_request_from_value(self.locale(), args)?; let tool = self.clone(); Ok(ToolExecution::new(move |stream_sender| async move { - let start = std::time::Instant::now(); + let start = crate::time_compat::Instant::now(); let response = tool.run_request_with_stream(req, stream_sender).await; tool_output_from_response(response, start.elapsed()) })) @@ -400,7 +400,7 @@ mod tests { }, ) .build(); - let start = std::time::Instant::now(); + let start = crate::time_compat::Instant::now(); let resp = tool .execute(ToolRequest { @@ -429,7 +429,7 @@ mod tests { }, ) .build(); - let start = std::time::Instant::now(); + let start = crate::time_compat::Instant::now(); let resp = tool .execute_with_status( diff --git a/crates/bashkit/src/time_compat.rs b/crates/bashkit/src/time_compat.rs new file mode 100644 index 000000000..ae97bdbc2 --- /dev/null +++ b/crates/bashkit/src/time_compat.rs @@ -0,0 +1,48 @@ +//! Cross-platform `Instant`/`SystemTime`. +//! +//! `std::time::Instant`/`SystemTime` panic unconditionally on +//! wasm32-unknown-unknown ("time not implemented on this platform" — no OS +//! clock, no extension point to satisfy; see +//! `library/std/src/sys/time/unsupported.rs` in the Rust source). `web-time` +//! is a drop-in replacement backed by `Performance.now()`/`Date.now()` via +//! web-sys on that target, and a transparent re-export of `std::time` +//! everywhere else (including wasm32-wasip1-threads, which has real WASI +//! clocks). Use this module's `Instant`/`SystemTime`/`UNIX_EPOCH` instead of +//! `std::time`'s directly anywhere wall-clock time is read. + +#[cfg(target_arch = "wasm32")] +pub(crate) use web_time::{Instant, SystemTime, UNIX_EPOCH}; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +/// Convert to a `chrono::DateTime`. +/// +/// `chrono` implements `From` but not +/// `From` — same API shape, different type, so the +/// blanket impl doesn't apply on wasm32. Goes through `duration_since` +/// instead, which both `SystemTime`s support identically. +pub(crate) fn to_chrono_utc(t: SystemTime) -> chrono::DateTime { + let epoch = || chrono::DateTime::from_timestamp(0, 0).expect("epoch is representable"); + match t.duration_since(UNIX_EPOCH) { + Ok(dur) => chrono::DateTime::from_timestamp(dur.as_secs() as i64, dur.subsec_nanos()) + .unwrap_or_else(epoch), + Err(e) => { + let dur = e.duration(); + chrono::DateTime::from_timestamp(-(dur.as_secs() as i64), 0).unwrap_or_else(epoch) + } + } +} + +/// Convert from a `chrono::DateTime`. Mirror of [`to_chrono_utc`] — see there +/// for why this can't just be a `From`/`Into` conversion. +pub(crate) fn from_chrono(dt: chrono::DateTime) -> SystemTime { + let utc = dt.with_timezone(&chrono::Utc); + let secs = utc.timestamp(); + let nanos = utc.timestamp_subsec_nanos(); + if secs >= 0 { + UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos) + } else { + UNIX_EPOCH - std::time::Duration::new((-secs) as u64, 0) + } +} diff --git a/crates/bashkit/src/tool.rs b/crates/bashkit/src/tool.rs index ecc83d06b..f42d4c65c 100644 --- a/crates/bashkit/src/tool.rs +++ b/crates/bashkit/src/tool.rs @@ -1025,7 +1025,7 @@ impl Tool for BashTool { let tool = self.clone(); Ok(ToolExecution::new(move |stream_sender| async move { - let start = std::time::Instant::now(); + let start = crate::time_compat::Instant::now(); let response = tool.run_request_with_stream(req, stream_sender).await; tool_output_from_response(response, start.elapsed()) })) diff --git a/crates/bashkit/tests/integration/main.rs b/crates/bashkit/tests/integration/main.rs index 1ae97990d..6ee3a90a6 100644 --- a/crates/bashkit/tests/integration/main.rs +++ b/crates/bashkit/tests/integration/main.rs @@ -94,6 +94,7 @@ pub mod subst_depth_limit_tests; pub mod symlink_overlay_security_tests; pub mod threat_model_doc_tests; pub mod threat_model_tests; +pub mod time_compat_scan_tests; pub mod tty_tests; pub mod typescript_integration_tests; pub mod typescript_security_tests; diff --git a/crates/bashkit/tests/integration/time_compat_scan_tests.rs b/crates/bashkit/tests/integration/time_compat_scan_tests.rs new file mode 100644 index 000000000..27afd962d --- /dev/null +++ b/crates/bashkit/tests/integration/time_compat_scan_tests.rs @@ -0,0 +1,82 @@ +// Regression guard for wasm32-unknown-unknown support. +// std::time::{Instant, SystemTime} panic at runtime on that target ("time not +// implemented on this platform"), so all wall-clock reads in the bashkit crate +// must go through crate::time_compat, which swaps in web-time on wasm32. +// This scan keeps new std::time::{Instant, SystemTime, UNIX_EPOCH} usage from +// creeping back in; the CI `cargo check --target wasm32-unknown-unknown` job +// catches breakage this lexical scan can't see (e.g. dependency regressions). +// +// Escape hatch: end the line with `// std-time-ok: ` for code that is +// provably never compiled on wasm32 and has a real reason to bypass the shim. + +use std::path::{Path, PathBuf}; + +fn src_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src") +} + +fn rust_files(dir: &Path, out: &mut Vec) { + let entries = + std::fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display())); + for entry in entries { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + rust_files(&path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + out.push(path); + } + } +} + +/// Everything except the shim itself must use `crate::time_compat` for +/// `Instant` / `SystemTime` / `UNIX_EPOCH`. +#[test] +fn std_time_clock_types_only_used_via_time_compat() { + let mut files = Vec::new(); + rust_files(&src_dir(), &mut files); + assert!( + files.iter().any(|f| f.ends_with("time_compat.rs")), + "expected src/time_compat.rs to exist" + ); + + let mut violations = Vec::new(); + for file in &files { + if file.ends_with("time_compat.rs") { + continue; + } + let content = std::fs::read_to_string(file) + .unwrap_or_else(|e| panic!("read {}: {e}", file.display())); + for (idx, line) in content.lines().enumerate() { + let code = line.trim_start(); + // Doc comments may show std::time in public-API examples; doctests + // compile against the public API on native, where that is correct. + if code.starts_with("//") { + continue; + } + if code.contains("// std-time-ok:") { + continue; + } + let clock_types = ["Instant", "SystemTime", "UNIX_EPOCH"]; + let qualified = clock_types + .iter() + .any(|t| code.contains(&format!("std::time::{t}"))); + // Braced imports like `use std::time::{Duration, Instant};`. + let braced = code + .split("std::time::{") + .nth(1) + .and_then(|rest| rest.split('}').next()) + .is_some_and(|list| clock_types.iter().any(|t| list.contains(t))); + if qualified || braced { + violations.push(format!("{}:{}: {}", file.display(), idx + 1, code)); + } + } + } + + assert!( + violations.is_empty(), + "std::time::{{Instant, SystemTime, UNIX_EPOCH}} panic on \ + wasm32-unknown-unknown; use crate::time_compat instead (or mark the \ + line `// std-time-ok: ` if it can never be compiled for wasm32):\n{}", + violations.join("\n") + ); +} diff --git a/supply-chain/config.toml b/supply-chain/config.toml index f0a83b94e..6225576a1 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -2147,6 +2147,10 @@ criteria = "safe-to-deploy" version = "0.3.102" criteria = "safe-to-deploy" +[[exemptions.web-time]] +version = "1.1.0" +criteria = "safe-to-deploy" + [[exemptions.webpki-root-certs]] version = "1.0.7" criteria = "safe-to-deploy" From 5e16ebab4f35f5c5fe216485c94488fa10169223 Mon Sep 17 00:00:00 2001 From: Preetham Mysore Date: Sun, 5 Jul 2026 11:23:20 -0400 Subject: [PATCH 2/2] fix(wasm): preserve subsecond precision for pre-epoch time_compat conversions duration_since's Err branch dropped nanos when negating whole seconds, so times before UNIX_EPOCH (e.g. epoch - 500ms) collapsed to the wrong second in both to_chrono_utc and from_chrono. Decompose using chrono's floor+remainder convention instead, and add round-trip tests covering the pre-epoch subsecond case. --- crates/bashkit/src/time_compat.rs | 77 ++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/bashkit/src/time_compat.rs b/crates/bashkit/src/time_compat.rs index ae97bdbc2..e17414350 100644 --- a/crates/bashkit/src/time_compat.rs +++ b/crates/bashkit/src/time_compat.rs @@ -28,8 +28,21 @@ pub(crate) fn to_chrono_utc(t: SystemTime) -> chrono::DateTime { Ok(dur) => chrono::DateTime::from_timestamp(dur.as_secs() as i64, dur.subsec_nanos()) .unwrap_or_else(epoch), Err(e) => { + // `dur` is how far *before* the epoch `t` is. chrono represents + // negative timestamps as (floor_secs, nanos) where nanos is the + // non-negative remainder added back — e.g. epoch - 500ms is + // (-1, 500_000_000), not (0, 0). Preserve that decomposition + // instead of truncating the subsecond part. let dur = e.duration(); - chrono::DateTime::from_timestamp(-(dur.as_secs() as i64), 0).unwrap_or_else(epoch) + let (secs, nanos) = 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(), + ) + }; + chrono::DateTime::from_timestamp(secs, nanos).unwrap_or_else(epoch) } } } @@ -42,7 +55,67 @@ pub(crate) fn from_chrono(dt: chrono::DateTime) -> Sys let nanos = utc.timestamp_subsec_nanos(); if secs >= 0 { UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos) - } else { + } else if nanos == 0 { UNIX_EPOCH - std::time::Duration::new((-secs) as u64, 0) + } else { + // Mirror of the decomposition in `to_chrono_utc`: `secs` is the + // floor, so the actual distance before the epoch is one second + // less than `-secs`, plus the complementary nanos. + UNIX_EPOCH - std::time::Duration::new((-secs - 1) as u64, 1_000_000_000 - nanos) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn to_chrono_utc_epoch() { + let dt = to_chrono_utc(UNIX_EPOCH); + assert_eq!((dt.timestamp(), dt.timestamp_subsec_nanos()), (0, 0)); + } + + #[test] + fn to_chrono_utc_pre_epoch_whole_second() { + let t = UNIX_EPOCH - std::time::Duration::from_secs(2); + let dt = to_chrono_utc(t); + assert_eq!((dt.timestamp(), dt.timestamp_subsec_nanos()), (-2, 0)); + } + + #[test] + fn to_chrono_utc_pre_epoch_subsecond() { + // Reviewer's case: 500ms before the epoch must round-trip as + // 1969-12-31T23:59:59.500Z, i.e. (-1, 500_000_000) — not (0, 0). + let t = UNIX_EPOCH - std::time::Duration::from_millis(500); + let dt = to_chrono_utc(t); + assert_eq!( + (dt.timestamp(), dt.timestamp_subsec_nanos()), + (-1, 500_000_000) + ); + assert_eq!(dt.to_rfc3339(), "1969-12-31T23:59:59.500+00:00"); + } + + #[test] + fn from_chrono_pre_epoch_subsecond() { + let dt = chrono::DateTime::from_timestamp(-1, 500_000_000).unwrap(); + let t = from_chrono(dt); + assert_eq!( + t.duration_since(UNIX_EPOCH).unwrap_err().duration(), + std::time::Duration::from_millis(500) + ); + } + + #[test] + fn round_trip_pre_epoch_subsecond() { + let original = UNIX_EPOCH - std::time::Duration::from_millis(500); + let round_tripped = from_chrono(to_chrono_utc(original)); + assert_eq!(round_tripped, original); + } + + #[test] + fn round_trip_post_epoch_subsecond() { + let original = UNIX_EPOCH + std::time::Duration::from_millis(500); + let round_tripped = from_chrono(to_chrono_utc(original)); + assert_eq!(round_tripped, original); } }