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
21 changes: 12 additions & 9 deletions crates/bashkit/src/builtins/date.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,8 @@ fn expand_nanoseconds(format: &str, nanos: u32) -> String {
}

/// Format an RFC 2822 date string from a UTC datetime.
fn format_rfc2822(dt: &DateTime<Utc>, utc: bool) -> String {
if utc {
fn format_rfc2822(dt: &DateTime<Utc>, force_utc: bool) -> String {
if force_utc {
dt.format("%a, %d %b %Y %H:%M:%S +0000").to_string()
} else {
let local_dt: DateTime<Local> = (*dt).into();
Expand All @@ -338,26 +338,26 @@ fn format_rfc2822(dt: &DateTime<Utc>, utc: bool) -> String {
}

/// Format an ISO 8601 date string.
fn format_iso8601(dt: &DateTime<Utc>, utc: bool, precision: &str) -> String {
fn format_iso8601(dt: &DateTime<Utc>, force_utc: bool, precision: &str) -> String {
match precision {
"hours" => {
if utc {
if force_utc {
dt.format("%Y-%m-%dT%H+00:00").to_string()
} else {
let local_dt: DateTime<Local> = (*dt).into();
local_dt.format("%Y-%m-%dT%H%:z").to_string()
}
}
"minutes" => {
if utc {
if force_utc {
dt.format("%Y-%m-%dT%H:%M+00:00").to_string()
} else {
let local_dt: DateTime<Local> = (*dt).into();
local_dt.format("%Y-%m-%dT%H:%M%:z").to_string()
}
}
"seconds" | "s" => {
if utc {
if force_utc {
dt.format("%Y-%m-%dT%H:%M:%S+00:00").to_string()
} else {
let local_dt: DateTime<Local> = (*dt).into();
Expand Down Expand Up @@ -460,15 +460,18 @@ impl Builtin for Date {
dt_utc = now;
};

let virtualized_clock = self.fixed_epoch.is_some() || self.offset_seconds != 0;
let force_utc = utc || epoch_input || virtualized_clock;

// Handle -R (RFC 2822) output
if rfc2822 {
let output = format_rfc2822(&dt_utc, utc);
let output = format_rfc2822(&dt_utc, force_utc);
return Ok(ExecResult::ok(format!("{}\n", output)));
}

// Handle -I (ISO 8601) output
if let Some(ref precision) = iso8601 {
let output = format_iso8601(&dt_utc, utc, precision);
let output = format_iso8601(&dt_utc, force_utc, precision);
return Ok(ExecResult::ok(format!("{}\n", output)));
}

Expand Down Expand Up @@ -501,7 +504,7 @@ impl Builtin for Date {

// Format the date, handling potential errors gracefully.
let mut output = String::new();
let format_result = if utc || epoch_input {
let format_result = if force_utc {
write!(output, "{}", dt_utc.format(&format))
} else {
let local_dt: DateTime<Local> = dt_utc.into();
Expand Down
17 changes: 17 additions & 0 deletions crates/bashkit/tests/threat_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3810,6 +3810,23 @@ mod tm_inf_018_date {
);
}

/// TM-INF-018: virtual clock modes must not leak host timezone via custom formats.
#[tokio::test]
async fn fixed_epoch_forces_utc_for_timezone_formats() {
let mut bash = Bash::builder().fixed_epoch(1_700_000_000).build();
let r = bash.exec("date +%z").await.unwrap();
assert_eq!(r.exit_code, 0);
assert_eq!(r.stdout.trim(), "+0000");
}

/// TM-INF-018: epoch_offset mode must not leak host timezone via RFC 2822 output.
#[tokio::test]
async fn epoch_offset_forces_utc_for_rfc2822() {
let mut bash = Bash::builder().epoch_offset(86_400).build();
let r = bash.exec("date -R").await.unwrap();
assert_eq!(r.exit_code, 0);
assert!(r.stdout.trim_end().ends_with(" +0000"));
}
/// TM-INF-018: `fixed_epoch` and `epoch_offset` are mutually
/// exclusive — last builder call wins. fixed_epoch followed by
/// epoch_offset should disable fixed_epoch.
Expand Down
Loading