From 62bf62136076606b6def4d01b00758c1e4c5b8aa Mon Sep 17 00:00:00 2001 From: xmakro Date: Thu, 8 Oct 2026 23:00:56 -0700 Subject: [PATCH] fix(date): accept HH:MM date-times and zone suffixes in -d `date -d` (and `touch -d`, `find -newermt`, which share the parser) only took ISO dates with full seconds and no zone. It now also reads `YYYY-MM-DD HH:MM`, an optional fraction of a second, and a trailing `UTC`/`GMT`, `Z` or numeric offset (`+0200`, `+02:00`, `-05`), which covers git's `%ci` format. --- crates/bashkit/docs/compatibility.md | 2 +- crates/bashkit/src/builtins/date.rs | 84 ++++++++++++++++--- .../date_timezone_differential_tests.rs | 12 +++ .../tests/spec_cases/bash/date.test.sh | 17 ++++ 4 files changed, 102 insertions(+), 13 deletions(-) diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index c59f957cb..1dfa2d557 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) | Display/format date with sandboxed IANA timezone support | +| `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 | | `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 35aca11a1..b395583c8 100644 --- a/crates/bashkit/src/builtins/date.rs +++ b/crates/bashkit/src/builtins/date.rs @@ -307,18 +307,43 @@ fn parse_base_date( .ok_or_else(|| format!("date out of range: '{}'", s)); } - // Try ISO-like formats: YYYY-MM-DD HH:MM:SS, YYYY-MM-DD - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { - return timezone.local_to_utc(dt, s); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") { - return timezone.local_to_utc(dt, s); + // Try ISO-like formats: YYYY-MM-DD, optionally with [ T]HH:MM[:SS[.frac]], + // read in the sandbox TZ unless followed by UTC/GMT, Z or a numeric offset + // (git's %ci) + const ISO: [&str; 4] = [ + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%d %H:%M", + "%Y-%m-%dT%H:%M", + ]; + let (wall, utc) = match s.rsplit_once(' ') { + Some((wall, zone)) + if zone.eq_ignore_ascii_case("utc") || zone.eq_ignore_ascii_case("gmt") => + { + (wall, true) + } + _ => (s, false), + }; + let naive = ISO + .iter() + .find_map(|f| NaiveDateTime::parse_from_str(wall, f).ok()) + .or_else(|| { + NaiveDate::parse_from_str(wall, "%Y-%m-%d") + .ok()? + .and_hms_opt(0, 0, 0) + }); + if let Some(dt) = naive { + return if utc { + Ok(dt.and_utc()) + } else { + timezone.local_to_utc(dt, s) + }; } - if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { - let dt = d - .and_hms_opt(0, 0, 0) - .ok_or_else(|| format!("invalid date '{}'", s))?; - return timezone.local_to_utc(dt, s); + if let Some(dt) = ISO + .iter() + .find_map(|f| DateTime::parse_from_str(s, &format!("{f} %#z")).ok()) + { + return Ok(dt.with_timezone(&Utc)); } // Try "Mon DD, YYYY" format @@ -355,7 +380,7 @@ fn parse_base_date( /// Supports simple expressions: /// "now", "yesterday", "tomorrow", "N days ago", "+N days", /// "N weeks ago", "N months ago", "N years ago", "N hours ago", -/// "@EPOCH", "YYYY-MM-DD", "YYYY-MM-DD HH:MM:SS" +/// "@EPOCH", "YYYY-MM-DD[ HH:MM[:SS[.frac]]][ UTC|+HHMM]" /// /// Supports compound expressions (base ± modifier): /// "2024-01-15 + 30 days", "yesterday - 2 hours", @@ -990,6 +1015,41 @@ mod tests { assert!(result.stderr.contains("invalid date")); } + /// Expected values match GNU date 9.12. + #[tokio::test] + async fn test_date_d_iso_times_and_zones() { + for (input, expected) in [ + ("2026-10-08 14:30", "2026-10-08 14:30:00.000000000"), + ("2026-10-08T14:30", "2026-10-08 14:30:00.000000000"), + ("2026-10-08 14:30:15.25", "2026-10-08 14:30:15.250000000"), + ("2026-10-08 14:30 UTC", "2026-10-08 14:30:00.000000000"), + ("2026-10-08 14:30:15 gmt", "2026-10-08 14:30:15.000000000"), + ("2026-10-08T14:30Z", "2026-10-08 14:30:00.000000000"), + ("2026-10-08 14:30:15 +0200", "2026-10-08 12:30:15.000000000"), + ("2026-10-08 14:30:15+02:00", "2026-10-08 12:30:15.000000000"), + ("2026-10-08 14:30:15 -05", "2026-10-08 19:30:15.000000000"), + ("2026-10-08 UTC", "2026-10-08 00:00:00.000000000"), + ] { + let result = run_date(&["-u", "-d", input, "+%F %T.%N"]).await; + assert_eq!(result.stdout.trim(), expected, "{input}"); + } + } + + #[tokio::test] + async fn test_date_d_iso_invalid_fields() { + for input in [ + "2026-02-30 10:00", + "2026-10-08 24:00", + "2026-10-08 12:60", + "2026-10-08 -05", + "2026-10-08 14:30 +2500", + ] { + 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_d_relative_weeks() { let result = run_date(&["-d", "2 weeks ago", "+%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 72b8d2a61..c281100f1 100644 --- a/crates/bashkit/tests/integration/date_timezone_differential_tests.rs +++ b/crates/bashkit/tests/integration/date_timezone_differential_tests.rs @@ -70,6 +70,18 @@ async fn matches_gnu_date_for_timezone_sensitive_inputs() { "America/Chicago", &["-u", "-d", "2024-01-15T10:40:00-06:00", "+%s %T %Z %z"], ), + ( + "America/Chicago", + &["-d", "2024-07-15 10:40", "+%s %F %T %Z %z"], + ), + ( + "America/Chicago", + &["-d", "2024-07-15 10:40:00 UTC", "+%s %F %T %Z %z"], + ), + ( + "America/Chicago", + &["-d", "2024-07-15 10:40:00.5 +0200", "+%s %N %F %T %Z %z"], + ), ("Etc/GMT+6", &["-d", "@0", "+%F %T %Z %z"]), ("UTC", &["-d", "@0", "+%C %G %g %u %V %3N %6N %N"]), ] { diff --git a/crates/bashkit/tests/spec_cases/bash/date.test.sh b/crates/bashkit/tests/spec_cases/bash/date.test.sh index 8c79dea58..b7c7d1c36 100644 --- a/crates/bashkit/tests/spec_cases/bash/date.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/date.test.sh @@ -232,6 +232,23 @@ date -d '2024-06-15 - 30 days' +%Y-%m-%d 2024-05-16 ### end +### date_iso_without_seconds +date -u -d '2026-10-08 14:30' '+%F %T' +### expect +2026-10-08 14:30:00 +### end + +### date_iso_zone_suffix +# Named zones, Z, and git's %ci numeric offset +date -u -d '2026-10-08 14:30:15 UTC' '+%F %T' +date -u -d '2026-10-08T14:30Z' '+%F %T' +date -u -d '2026-10-08 14:30:15 +0200' '+%F %T' +### expect +2026-10-08 14:30:15 +2026-10-08 14:30:00 +2026-10-08 12:30:15 +### end + ### date_compound_date_plus_days date -d '2024-01-15 + 30 days' +%Y-%m-%d ### expect