Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/bashkit/docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
84 changes: 72 additions & 12 deletions crates/bashkit/src/builtins/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
] {
Expand Down
58 changes: 58 additions & 0 deletions crates/bashkit/tests/integration/date_timezone_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
}
17 changes: 17 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/date.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading