diff --git a/src/handlers/agent/sessions.rs b/src/handlers/agent/sessions.rs index 56524651..2e0694cf 100644 --- a/src/handlers/agent/sessions.rs +++ b/src/handlers/agent/sessions.rs @@ -6,11 +6,15 @@ use std::{ }; use anyhow::{Context, Result}; -use chrono::{DateTime, SecondsFormat, Utc}; +use chrono::Utc; use serde::{Deserialize, Serialize}; use tabled::Tabled; -use crate::utils::{interaction, spinner::request_spinner}; +use crate::utils::{ + human_datetime::{format_utc, format_utc_timestamp}, + interaction, + spinner::request_spinner, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalAgentSession { @@ -36,7 +40,7 @@ impl LocalAgentSessionGuard { let session = LocalAgentSession { session_id: session_id.clone(), - started_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + started_at: format_utc(Utc::now()), process_id: std::process::id(), process_started_at: process_start_time(std::process::id())?, revoked: false, @@ -196,12 +200,6 @@ impl From for AgentSessionRow { } } -fn format_utc_timestamp(value: &str) -> Result { - Ok(DateTime::parse_from_rfc3339(value)? - .with_timezone(&Utc) - .to_rfc3339_opts(SecondsFormat::Secs, true)) -} - pub fn format_sessions(rows: &Vec, json: bool) -> Result<()> { if json { println!( @@ -219,8 +217,8 @@ pub fn format_sessions(rows: &Vec, json: bool) -> Result<()> { #[cfg(test)] mod tests { use super::{ - format_utc_timestamp, is_valid_agent_session_id, process_start_time, - should_confirm_bulk_revoke, validate_bulk_scope, AgentSessionRow, + is_valid_agent_session_id, process_start_time, should_confirm_bulk_revoke, + validate_bulk_scope, AgentSessionRow, }; #[test] @@ -231,18 +229,6 @@ mod tests { assert_eq!(process_start_time(pid).unwrap(), first); } - #[test] - fn session_timestamps_are_utc_seconds() { - assert_eq!( - format_utc_timestamp("2026-09-13T11:53:59.237435+00:00").unwrap(), - "2026-09-13T11:53:59Z" - ); - assert_eq!( - format_utc_timestamp("2026-07-03T16:10:07+02:00").unwrap(), - "2026-07-03T14:10:07Z" - ); - } - #[test] fn session_rows_do_not_include_expiration() { let row = AgentSessionRow { diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 3b2440d7..047e8f32 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -413,7 +413,8 @@ pub(crate) struct ExistingRunNetwork { /// `None` for the pre-session-naming fallback (a bare UUID) — see /// `generate_run_network_name`. pub session_id: Option, - /// RFC 3339 creation timestamp, straight from `docker network inspect`. + /// Creation timestamp as UTC RFC 3339 (see `format_utc`), or empty when + /// `docker network inspect` couldn't report it. pub created_at: String, } @@ -453,7 +454,7 @@ pub(crate) fn list_run_networks() -> Result, String> { .output() .map_err(|error| format!("failed to run `docker network inspect`: {error}"))?; let created_at = if inspect.status.success() { - String::from_utf8_lossy(&inspect.stdout).trim().to_owned() + normalize_network_created_at(&String::from_utf8_lossy(&inspect.stdout)) } else { // The network could have been removed between the `ls` and // this `inspect` (e.g. a concurrent run finishing normally) @@ -647,6 +648,13 @@ pub(crate) const STALE_IMAGE_AFTER_DAYS: i64 = 14; /// When the local image for `source` was built, or `None` if it isn't /// built (or `docker` can't say). Not meaningful for a plain image /// reference, which `docker run` pulls on demand. +/// Turns `docker network inspect --format '{{.Created}}'` output into a UTC +/// RFC 3339 timestamp, keeping the raw value if it doesn't parse. +fn normalize_network_created_at(output: &str) -> String { + let raw = output.trim().trim_matches('"'); + crate::utils::human_datetime::display_utc_timestamp(raw) +} + pub(crate) fn sandbox_image_created_at( source: &AgentImageSource, ) -> Option> { @@ -1831,6 +1839,20 @@ mod tests { assert!(arg_line < install_line); } + #[test] + fn network_created_at_is_normalized_to_utc() { + // Docker Engine exposes `.Created` as an RFC 3339 string. + assert_eq!( + normalize_network_created_at("2026-10-09T09:57:15.791491833+02:00\n"), + "2026-10-09T07:57:15Z" + ); + assert_eq!( + normalize_network_created_at("2026-10-09T07:57:15.791491833Z"), + "2026-10-09T07:57:15Z" + ); + assert_eq!(normalize_network_created_at("not-a-date"), "not-a-date"); + } + #[test] fn format_image_age_reads_naturally() { let now = chrono::Utc::now(); diff --git a/src/handlers/secrets/metadata.rs b/src/handlers/secrets/metadata.rs index 4d6cba6a..64e77e6c 100644 --- a/src/handlers/secrets/metadata.rs +++ b/src/handlers/secrets/metadata.rs @@ -13,6 +13,7 @@ use crate::{ validation::InputValidationError, }, utils::{ + human_datetime::display_utc_timestamp, output::{get_formatted_json_string, ColorizeIfColoredOutput}, spinner::request_spinner, tables::build::build_table, @@ -257,7 +258,8 @@ fn print_secret_metadata_list(secret_metadata: Vec, format: &Sec .iter() .map(|s| { let comment = s.comment.clone().unwrap_or_default(); - let last_accessed_at = s.last_accessed_at.clone().unwrap_or_default(); + let last_accessed_at = + display_utc_timestamp(&s.last_accessed_at.clone().unwrap_or_default()); format!( "{}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}", @@ -269,9 +271,9 @@ fn print_secret_metadata_list(secret_metadata: Vec, format: &Sec "has_value:".blue_if_tty(), s.has_value, "created_at:".blue_if_tty(), - s.created_at, + display_utc_timestamp(&s.created_at), "updated_at:".blue_if_tty(), - s.updated_at, + display_utc_timestamp(&s.updated_at), "last_accessed_at:".blue_if_tty(), last_accessed_at, ) @@ -317,7 +319,8 @@ fn print_secret_metadata(secret_metadata: SecretMetadata, format: &SecretsOutput } SecretsOutputFormat::Plain | SecretsOutputFormat::Dotenv => { let comment = secret_metadata.comment.unwrap_or_default(); - let last_accessed_at = secret_metadata.last_accessed_at.unwrap_or_default(); + let last_accessed_at = + display_utc_timestamp(&secret_metadata.last_accessed_at.unwrap_or_default()); println!( "{}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}", @@ -329,9 +332,9 @@ fn print_secret_metadata(secret_metadata: SecretMetadata, format: &SecretsOutput "has_value:".blue_if_tty(), secret_metadata.has_value, "created_at:".blue_if_tty(), - secret_metadata.created_at, + display_utc_timestamp(&secret_metadata.created_at), "updated_at:".blue_if_tty(), - secret_metadata.updated_at, + display_utc_timestamp(&secret_metadata.updated_at), "last_accessed_at:".blue_if_tty(), last_accessed_at, ); diff --git a/src/models/secrets.rs b/src/models/secrets.rs index be49e159..e87b79fe 100644 --- a/src/models/secrets.rs +++ b/src/models/secrets.rs @@ -13,6 +13,7 @@ use crate::{ cmd::config::SecretsOutputFormat, utils::{ self, + human_datetime::display_utc_timestamp, output::{is_color_enabled, write_indented, ColorizeIfColoredOutput}, }, }; @@ -187,9 +188,9 @@ impl From for SecretMetadataTable { comment: value.comment.unwrap_or_default(), version: value.version, has_value: value.has_value, - created_at: value.created_at, - updated_at: value.updated_at, - last_accessed_at: value.last_accessed_at.unwrap_or_default(), + created_at: display_utc_timestamp(&value.created_at), + updated_at: display_utc_timestamp(&value.updated_at), + last_accessed_at: display_utc_timestamp(&value.last_accessed_at.unwrap_or_default()), } } } @@ -200,9 +201,9 @@ impl From for SecretMetadataTableWithoutComment { name: value.name, version: value.version, has_value: value.has_value, - created_at: value.created_at, - updated_at: value.updated_at, - last_accessed_at: value.last_accessed_at.unwrap_or_default(), + created_at: display_utc_timestamp(&value.created_at), + updated_at: display_utc_timestamp(&value.updated_at), + last_accessed_at: display_utc_timestamp(&value.last_accessed_at.unwrap_or_default()), } } } diff --git a/src/utils/human_datetime.rs b/src/utils/human_datetime.rs index 32677f5e..3dcce6b7 100644 --- a/src/utils/human_datetime.rs +++ b/src/utils/human_datetime.rs @@ -1,18 +1,34 @@ -use chrono::prelude::DateTime; -use chrono::Local; +use anyhow::Result; +use chrono::{DateTime, SecondsFormat, Utc}; use chrono_humanize::HumanTime; use log::debug; +/// Formats an instant the way every absolute date in the CLI is shown: +/// UTC, whole seconds, RFC 3339 with a `Z` (e.g. `2026-09-13T11:53:59Z`). +pub fn format_utc(date_time: DateTime) -> String { + date_time.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +/// Parses an RFC 3339 timestamp in any offset and re-formats it with +/// [`format_utc`]. +pub fn format_utc_timestamp(value: &str) -> Result { + Ok(format_utc( + DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc), + )) +} + +/// [`format_utc_timestamp`] for display: anything that doesn't parse +/// (including an empty "never" value) is shown unchanged. +pub fn display_utc_timestamp(value: &str) -> String { + format_utc_timestamp(value).unwrap_or_else(|_| value.to_owned()) +} + pub fn get_human_datetime(date_time_str: &str) -> (String, HumanTime) { match DateTime::parse_from_rfc3339(date_time_str) { Ok(d) => { - let datetime = DateTime::::from(d); - - let date_time = datetime.format("%Y-%m-%d %H:%M").to_string(); - let dt = datetime - chrono::Local::now(); - - let ht = HumanTime::from(dt); - return (date_time, ht); + let datetime = d.with_timezone(&Utc); + let ht = HumanTime::from(datetime - Utc::now()); + (format_utc(datetime), ht) } Err(e) => { debug!("{:#?}", &e); @@ -23,3 +39,35 @@ pub fn get_human_datetime(date_time_str: &str) -> (String, HumanTime) { } } } + +#[cfg(test)] +mod tests { + use super::{display_utc_timestamp, format_utc_timestamp, get_human_datetime}; + + #[test] + fn timestamps_are_utc_seconds() { + assert_eq!( + format_utc_timestamp("2026-09-13T11:53:59.237435+00:00").unwrap(), + "2026-09-13T11:53:59Z" + ); + assert_eq!( + format_utc_timestamp("2026-07-03T16:10:07+02:00").unwrap(), + "2026-07-03T14:10:07Z" + ); + } + + #[test] + fn human_datetime_is_utc_regardless_of_local_timezone() { + let (formatted, _) = get_human_datetime("2026-07-03T16:10:07.5+02:00"); + assert_eq!(formatted, "2026-07-03T14:10:07Z"); + } + + #[test] + fn display_keeps_unparseable_values() { + assert_eq!( + display_utc_timestamp("2026-07-03T16:10:07+02:00"), + "2026-07-03T14:10:07Z" + ); + assert_eq!(display_utc_timestamp(""), ""); + } +}