diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 638699618..333eda947 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/integration/date_timezone_tests.rs b/crates/bashkit/tests/integration/date_timezone_tests.rs index 50db4a8fb..8a3b46a81 100644 --- a/crates/bashkit/tests/integration/date_timezone_tests.rs +++ b/crates/bashkit/tests/integration/date_timezone_tests.rs @@ -150,3 +150,61 @@ async fn gnu_nanosecond_precision_uses_validated_formatter() { assert_eq!(result.exit_code, 0, "{}", result.stderr); assert_eq!(result.stdout.trim(), "000 000000 000000000"); } + +#[tokio::test] +async fn shared_iso_date_parsing_preserves_instants_across_builtins() { + for (input, expected) in [ + ("2024-01-15 10:40", "2024-01-15 16:40:00.000000000"), + ("2024-01-15 10:40 UTC", "2024-01-15 10:40:00.000000000"), + ("2024-01-15 10:40 gmt", "2024-01-15 10:40:00.000000000"), + ("2024-01-15T10:40Z", "2024-01-15 10:40:00.000000000"), + ( + "2024-01-15 10:40:00.5 +0200", + "2024-01-15 08:40:00.500000000", + ), + ("2024-01-15 10:40 -05", "2024-01-15 15:40:00.000000000"), + ] { + let script = format!( + "date -u -d '{input}' '+%F %T.%N'; \ + touch -d '{input}' /tmp/stamp && date -u -r /tmp/stamp '+%F %T.%N'; \ + find /tmp/stamp -newermt '{input}'; \ + find /tmp/stamp -newermt '{input} + 1 second'; \ + find /tmp/stamp -newermt '{input} - 1 second'" + ); + let result = fixed_date(Some("America/Chicago"), &script).await; + assert_eq!(result.exit_code, 0, "{input}: {}", result.stderr); + assert!(result.stderr.is_empty(), "{input}: {}", result.stderr); + assert_eq!( + result.stdout, + format!("{expected}\n{expected}\n/tmp/stamp\n"), + "{input}" + ); + } +} + +#[tokio::test] +async fn shared_iso_date_parsing_rejects_invalid_inputs_before_file_effects() { + for input in [ + "2024-03-10 02:30", // DST gap in the sandbox timezone. + "2024-02-30 10:40", + "2024-07-15 24:00", + "2024-07-15 10:40 +2500", + "2024-07-15 10:40 +0260", + "2024-07-15 10:40 UTC trailing", + "2024-07-15 10:40 /etc/localtime", + ] { + let mut bash = Bash::builder().env("TZ", "America/Chicago").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(), "{script}"); + } + let result = bash.exec("test -e /tmp/rejected").await.unwrap(); + assert_eq!(result.exit_code, 1, "invalid touch created a file: {input}"); + } +} 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 diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 5481bd635..19c466ad7 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -139,6 +139,18 @@ builtins are currently tracked; partial boundaries follow. | L-DATE-001 | date | `TZ` accepts bundled IANA identifiers/aliases only. POSIX rule strings and `:zoneinfo` paths are unsupported and intentionally resolve to UTC because the sandbox has no trusted host zoneinfo filesystem. GNU nanosecond formatting supports the useful `%N`/`%3N`/`%6N`/`%9N` forms, not other widths | `date_timezone_tests` | | L-MAPFILE-001 | mapfile | `-u FD` reads only fd 0, and the `-C`/`-c` callback is not run; both exit 2 with a message instead of reading the wrong input | `mapfile_bad_option` (param-expansion-gaps spec) | +`date -d`, `touch -d`, and `find -newermt` share ISO date-string parsing: +`YYYY-MM-DD` and date-times with a space or `T`, `HH:MM`, optional seconds, +and optional fractional seconds. Naive values use the sandbox `TZ`; explicit +`UTC`/`GMT`, `Z`, and numeric offsets (`+0200`, `+02:00`, `-05`) determine the +instant independently of that zone. Date-only values also accept `UTC`/`GMT`. +`date -u` changes display only. Other named input zones, comma fractions, +date-only `Z`, and offsets outside chrono's range remain rejected. Host zoneinfo +is never consulted. `shared_iso_date_parsing_*` tests cover shared instants, +fractional file timestamps, strict newer-than comparisons, and rejection before +file effects; `date_timezone_differential_tests` compares supported forms with +GNU date when available. + ### CPython runtime (`cpython` feature) Boundaries of the WebAssembly CPython guest; see