diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 333eda947..641157527 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -91,7 +91,7 @@ for sandbox security reasons. See the compliance spec for details. | `uniq` | `-c`, `-d`, `-u` | Filter duplicate lines | | `cut` | `-d DELIM`, `-f FIELDS` | Extract fields | | `tr` | `-d`, character ranges | Translate/delete chars | -| `date` | `+FORMAT`, `-u`, `-R`, `-I`, `-r`, `-d`/`--date` (relative, compound, epoch, ISO date-times with optional seconds and zone) | Display/format date with sandboxed IANA timezone support | +| `date` | `+FORMAT`, `-u`, `-R`, `-I`, `-r`, `-d`/`--date` (relative, weekday, compound, epoch, ISO date-times with optional seconds and zone) | Display/format date with sandboxed IANA timezone support | | `wait` | `[-n] [-p VAR] [-f] [ID...]` (PID or `%jobspec`) | Wait for background jobs | | `jobs` | `-l`, `-p` | List running background jobs | | `ps` | `-f`, `aux`, `-o`, `-p` | Virtual process list (shell + jobs) | diff --git a/crates/bashkit/src/builtins/date.rs b/crates/bashkit/src/builtins/date.rs index b395583c8..176cf8080 100644 --- a/crates/bashkit/src/builtins/date.rs +++ b/crates/bashkit/src/builtins/date.rs @@ -10,8 +10,12 @@ use async_trait::async_trait; use super::arg_parser::OptArg; +#[cfg(feature = "tzdata")] +use chrono::Offset; use chrono::format::{Item, StrftimeItems}; -use chrono::{DateTime, Duration, LocalResult, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use chrono::{ + DateTime, Datelike, Duration, LocalResult, NaiveDate, NaiveDateTime, TimeZone, Utc, Weekday, +}; #[cfg(feature = "tzdata")] use chrono_tz::Tz; @@ -147,6 +151,19 @@ impl SandboxTimezone { Self::Iana(tz) => dt.with_timezone(&tz).format(format).to_string(), } } + + fn date_naive(self, dt: &DateTime) -> Option { + match self { + Self::Utc => Some(dt.date_naive()), + #[cfg(feature = "tzdata")] + // A host-supplied virtual clock can reach chrono's extrema; + // naive_local/date_naive panic if the zone pushes it past them. + Self::Iana(tz) => dt + .naive_utc() + .checked_add_offset(dt.with_timezone(&tz).offset().fix()) + .map(|local| local.date()), + } + } } impl Date { @@ -294,7 +311,7 @@ fn parse_base_date( // Special words match lower.as_str() { - "now" => return Ok(now), + "now" | "today" => return Ok(now), "yesterday" => return Ok(now - Duration::days(1)), "tomorrow" => return Ok(now + Duration::days(1)), _ => {} @@ -372,13 +389,41 @@ fn parse_base_date( return Ok(dt.with_timezone(&Utc)); } + // Day of week: "monday", "next fri", "last tuesday", at midnight. As in + // GNU, a bare day may be today, `next` skips today, `last` is the latest + // one before today. + let (which, day) = lower + .split_once(' ') + .map_or(("this", lower.as_str()), |(which, day)| { + (which, day.trim_start()) + }); + if let Ok(day) = day.parse::() { + let today = timezone + .date_naive(&now) + .ok_or_else(|| format!("date out of range: '{}'", s))?; + let ahead = i64::from(day.days_since(today.weekday())); + let days = match which { + "this" => ahead, + "next" if ahead == 0 => 7, + "next" => ahead, + "last" => ahead - 7, + _ => return Err(format!("date: invalid date '{}'", s)), + }; + let midnight = today + .checked_add_signed(Duration::days(days)) + .and_then(|d| d.and_hms_opt(0, 0, 0)) + .ok_or_else(|| format!("date out of range: '{}'", s))?; + return timezone.local_to_utc(midnight, s); + } + Err(format!("date: invalid date '{}'", s)) } /// Parse a date string like GNU date's -d flag. /// /// Supports simple expressions: -/// "now", "yesterday", "tomorrow", "N days ago", "+N days", +/// "now", "today", "yesterday", "tomorrow", "N days ago", "+N days", +/// "monday", "next friday", "last tuesday", /// "N weeks ago", "N months ago", "N years ago", "N hours ago", /// "@EPOCH", "YYYY-MM-DD[ HH:MM[:SS[.frac]]][ UTC|+HHMM]" /// @@ -472,7 +517,8 @@ fn unit_duration(unit: &str, n: i64) -> Option { "week" => Duration::try_weeks(n), "month" => n.checked_mul(30).and_then(Duration::try_days), // Approximate "year" => n.checked_mul(365).and_then(Duration::try_days), // Approximate - _ => Some(Duration::zero()), + // Not a unit (`next monday`): an invalid date, not a zero offset. + _ => None, } } @@ -1066,6 +1112,76 @@ mod tests { assert_eq!(date.len(), 10); } + #[cfg(feature = "tzdata")] + #[test] + fn weekday_timezone_conversion_checks_range() { + for (now, tz) in [ + (DateTime::::MAX_UTC, "Etc/GMT-14"), + (DateTime::::MIN_UTC, "Etc/GMT+12"), + ] { + let tz = SandboxTimezone::Iana(tz.parse().unwrap()); + assert!(parse_date_string("monday", now, tz).is_err()); + } + } + + #[test] + fn weekday_calendar_arithmetic_checks_range() { + let max = DateTime::::MAX_UTC; + let min = DateTime::::MIN_UTC; + assert!( + parse_date_string( + &format!("next {}", max.weekday()), + max, + SandboxTimezone::Utc + ) + .is_err() + ); + assert!( + parse_date_string( + &format!("last {}", min.weekday()), + min, + SandboxTimezone::Utc + ) + .is_err() + ); + } + + /// From Thursday 2026-10-08 12:00 UTC; expected days match GNU date. + #[test] + fn test_date_d_weekdays() { + let now = DateTime::from_timestamp(1_791_460_800, 0).unwrap(); + for (input, expected) in [ + ("thursday", "2026-10-08"), + ("next thursday", "2026-10-15"), + ("last thursday", "2026-10-01"), + ("monday", "2026-10-12"), + ("next mon", "2026-10-12"), + ("last Fri", "2026-10-02"), + ] { + let dt = parse_date_string(input, now, SandboxTimezone::Utc).unwrap(); + assert_eq!( + dt.format("%F %T").to_string(), + format!("{expected} 00:00:00"), + "{input}" + ); + } + assert_eq!( + parse_date_string("today", now, SandboxTimezone::Utc), + Ok(now) + ); + } + + /// `next ` used to read an unknown word as a zero offset and + /// print the current time. + #[tokio::test] + async fn test_date_d_unknown_word_is_invalid() { + for input in ["next blursday", "last blah"] { + let result = run_date(&["-d", input]).await; + assert_eq!(result.exit_code, 1, "{input}"); + assert!(result.stderr.contains("invalid date"), "{input}"); + } + } + #[tokio::test] async fn test_date_long_date_flag() { let result = run_date(&["--date=yesterday", "+%Y-%m-%d"]).await; diff --git a/crates/bashkit/tests/integration/date_timezone_differential_tests.rs b/crates/bashkit/tests/integration/date_timezone_differential_tests.rs index c281100f1..d96a5794d 100644 --- a/crates/bashkit/tests/integration/date_timezone_differential_tests.rs +++ b/crates/bashkit/tests/integration/date_timezone_differential_tests.rs @@ -88,3 +88,66 @@ async fn matches_gnu_date_for_timezone_sensitive_inputs() { assert_gnu_match(tz, args).await; } } + +#[tokio::test] +async fn matches_gnu_date_for_weekdays() { + let Some(program) = gnu_date() else { + eprintln!("skip: GNU date is not installed"); + return; + }; + for tz in ["UTC", "America/Chicago", "Australia/Sydney"] { + for input in [ + "monday", + "this Mon", + "next tuesday", + "last SUN", + "thursday", + "next Thursday", + "last thursday", + "this fri", + "saturday", + "next blursday", + "last blah", + ] { + // Freeze Bashkit at GNU's reference instant. If the host crosses + // local midnight during the pair, that case has no stable oracle. + let reference = Command::new(&program) + .args(["+%s %F"]) + .env("TZ", tz) + .output() + .unwrap(); + assert!(reference.status.success()); + let reference = String::from_utf8(reference.stdout).unwrap(); + let (epoch, day) = reference.trim().split_once(' ').unwrap(); + let host = Command::new(&program) + .args(["-d", input, "+%s %F %T %z"]) + .env("TZ", tz) + .env("LC_ALL", "C") + .output() + .unwrap(); + let after = Command::new(&program) + .arg("+%F") + .env("TZ", tz) + .output() + .unwrap(); + if after.stdout != format!("{day}\n").as_bytes() { + eprintln!("skip: local midnight crossed during GNU weekday comparison"); + continue; + } + let mut bash = Bash::builder() + .fixed_epoch(epoch.parse().unwrap()) + .env("TZ", tz) + .build(); + let result = bash + .exec(&format!("date -d {} '+%s %F %T %z'", shell_quote(input))) + .await + .unwrap(); + assert_eq!( + result.exit_code, + host.status.code().unwrap_or(-1), + "TZ={tz} {input}" + ); + assert_eq!(result.stdout.as_bytes(), host.stdout, "TZ={tz} {input}"); + } + } +} diff --git a/crates/bashkit/tests/integration/date_timezone_no_tzdata_tests.rs b/crates/bashkit/tests/integration/date_timezone_no_tzdata_tests.rs index 8596aaf0f..c37a31747 100644 --- a/crates/bashkit/tests/integration/date_timezone_no_tzdata_tests.rs +++ b/crates/bashkit/tests/integration/date_timezone_no_tzdata_tests.rs @@ -133,3 +133,17 @@ async fn error_is_a_clean_diagnostic_not_a_panic_or_debug_dump() { assert!(stderr.len() < 200, "diagnostic too long: {stderr:?}"); assert!(!stderr.contains('{'), "looks like a Debug dump: {stderr:?}"); } + +#[tokio::test] +async fn weekdays_work_in_utc_without_tzdata() { + let result = fixed_date(Some("UTC"), "date -d monday '+%F %T'; date -d 'next monday' '+%F %T'; date -d 'last monday' '+%F %T'; date -d today +%s").await; + assert_eq!(result.exit_code, 0); + assert_eq!( + result.stdout, + "2024-01-15 00:00:00\n2024-01-22 00:00:00\n2024-01-08 00:00:00\n1705315200\n" + ); + let result = fixed_date(Some("America/Chicago"), "date -d monday +%s").await; + assert_eq!(result.exit_code, 1); + assert!(result.stdout.is_empty()); + assert!(result.stderr.contains(UNAVAILABLE)); +} diff --git a/crates/bashkit/tests/integration/date_timezone_tests.rs b/crates/bashkit/tests/integration/date_timezone_tests.rs index 8a3b46a81..c7ff63f3a 100644 --- a/crates/bashkit/tests/integration/date_timezone_tests.rs +++ b/crates/bashkit/tests/integration/date_timezone_tests.rs @@ -208,3 +208,93 @@ async fn shared_iso_date_parsing_rejects_invalid_inputs_before_file_effects() { assert_eq!(result.exit_code, 1, "invalid touch created a file: {input}"); } } + +#[tokio::test] +async fn shared_weekday_parsing_uses_sandbox_calendar_and_clock() { + // Monday in UTC and Sydney, still Sunday in Chicago. + const EPOCH: i64 = 1_705_277_400; // 2024-01-15 00:10 UTC + for (tz, input, expected) in [ + ("UTC", "monday", "2024-01-15 00:00:00 +0000"), + ("UTC", "this Mon", "2024-01-15 00:00:00 +0000"), + ("UTC", "next monday", "2024-01-22 00:00:00 +0000"), + ("UTC", "last monday", "2024-01-08 00:00:00 +0000"), + ("America/Chicago", "sunday", "2024-01-14 00:00:00 -0600"), + ("America/Chicago", "next sun", "2024-01-21 00:00:00 -0600"), + ("America/Chicago", "last SUN", "2024-01-07 00:00:00 -0600"), + ("Australia/Sydney", "MONDAY", "2024-01-15 00:00:00 +1100"), + ] { + let mut bash = Bash::builder().fixed_epoch(EPOCH).env("TZ", tz).build(); + let result = bash.exec(&format!( + "date -d '{input}' '+%F %T %z'; touch -d '{input}' /tmp/stamp; date -r /tmp/stamp '+%F %T %z'; find /tmp/stamp -newermt '{input} - 1 second'; find /tmp/stamp -newermt '{input}'" + )).await.unwrap(); + assert_eq!(result.exit_code, 0, "TZ={tz} {input}: {}", result.stderr); + assert_eq!( + result.stdout, + format!("{expected}\n{expected}\n/tmp/stamp\n") + ); + assert!(result.stderr.is_empty()); + } + let mut bash = Bash::builder() + .fixed_epoch(EPOCH) + .env("TZ", "America/Chicago") + .build(); + let result = bash + .exec("date -d today +%s; touch -d today /tmp/stamp; date -r /tmp/stamp +%s") + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, format!("{EPOCH}\n{EPOCH}\n")); +} + +#[tokio::test] +async fn shared_weekday_parsing_rejects_unknown_words_before_file_effects() { + for input in [ + "next blursday", + "last blah", + "next mins", + "this nonsense", + "next monday trailing", + "next /etc/localtime", + ] { + let mut bash = Bash::builder().fixed_epoch(WINTER_EPOCH).build(); + for script in [ + format!("date -d '{input}' +%s"), + format!("touch -d '{input}' /tmp/rejected"), + format!("find /tmp -newermt '{input}'"), + ] { + let result = bash.exec(&script).await.unwrap(); + assert_eq!(result.exit_code, 1, "{script}: {}", result.stderr); + assert!(result.stdout.is_empty(), "{script}: {}", result.stdout); + assert!(!result.stderr.is_empty()); + } + assert_eq!( + bash.exec("test -e /tmp/rejected").await.unwrap().exit_code, + 1 + ); + } +} + +#[tokio::test] +async fn weekday_midnight_observes_dst_rules() { + let mut bash = Bash::builder() + .fixed_epoch(1_710_093_600) + .env("TZ", "America/Chicago") + .build(); // 2024-03-10 18:00 UTC + let result = bash + .exec("date -d sunday '+%F %T %z'; date -d 'next sunday' '+%F %T %z'") + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!( + result.stdout, + "2024-03-10 00:00:00 -0600\n2024-03-17 00:00:00 -0500\n" + ); + // Sao Paulo skipped midnight on this date: reject instead of inventing an instant. + let mut bash = Bash::builder() + .fixed_epoch(1_541_332_800) + .env("TZ", "America/Sao_Paulo") + .build(); // 2018-11-04 12:00 UTC + let result = bash.exec("date -d sunday +%s").await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stdout.is_empty()); +} diff --git a/crates/bashkit/tests/spec_cases/bash/date.test.sh b/crates/bashkit/tests/spec_cases/bash/date.test.sh index b7c7d1c36..1e8ac1e0c 100644 --- a/crates/bashkit/tests/spec_cases/bash/date.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/date.test.sh @@ -171,6 +171,18 @@ date -d 'tomorrow' +%Y-%m-%d | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' && echo " valid ### end +### date_next_weekday +date -d 'next monday' '+%u %T' +### expect +1 00:00:00 +### end + +### date_unknown_word_is_invalid +date -d 'next blursday' 2>/dev/null || echo invalid +### expect +invalid +### end + ### date_set_time ### skip: date -s (set time) not implemented and requires privileges date -s '2024-01-01 12:00:00' 2>/dev/null || echo "skip" diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 19c466ad7..a6ff7f1e9 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -151,6 +151,18 @@ fractional file timestamps, strict newer-than comparisons, and rejection before file effects; `date_timezone_differential_tests` compares supported forms with GNU date when available. +The same parser accepts English full weekday names and three-letter +abbreviations (case-insensitive), bare or preceded by `this`, `next`, or `last`. +They resolve at midnight on the selected sandbox calendar day: bare/`this` +include today, `next` excludes today, and `last` selects the previous occurrence. +`today` preserves the current virtual instant, as `now` does. Unknown relative +units are errors; abbreviations such as `next mins`, numeric weekday ordinals, +and weekday zone suffixes remain unsupported. Midnight gaps are rejected and +folds choose the earlier instant, using the existing naive-date policy; virtual +clock conversion and weekday arithmetic reject dates outside chrono's range. +`shared_weekday_parsing_*`, `weekday_midnight_observes_dst_rules`, +`weekday_*_checks_range`, and the GNU-date differential cover these boundaries. + ### CPython runtime (`cpython` feature) Boundaries of the WebAssembly CPython guest; see