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, 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) |
Expand Down
124 changes: 120 additions & 4 deletions crates/bashkit/src/builtins/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -147,6 +151,19 @@ impl SandboxTimezone {
Self::Iana(tz) => dt.with_timezone(&tz).format(format).to_string(),
}
}

fn date_naive(self, dt: &DateTime<Utc>) -> Option<NaiveDate> {
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 {
Expand Down Expand Up @@ -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)),
_ => {}
Expand Down Expand Up @@ -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::<Weekday>() {
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]"
///
Expand Down Expand Up @@ -472,7 +517,8 @@ fn unit_duration(unit: &str, n: i64) -> Option<Duration> {
"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,
}
}

Expand Down Expand Up @@ -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::<Utc>::MAX_UTC, "Etc/GMT-14"),
(DateTime::<Utc>::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::<Utc>::MAX_UTC;
let min = DateTime::<Utc>::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 <word>` 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
}
}
14 changes: 14 additions & 0 deletions crates/bashkit/tests/integration/date_timezone_no_tzdata_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
90 changes: 90 additions & 0 deletions crates/bashkit/tests/integration/date_timezone_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
12 changes: 12 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/date.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions knowledge/operations/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading