From aad468e8b75ffc3e87ea6dfa22c53a8299fc08da Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 16 Aug 2026 00:29:03 +0900 Subject: feat(php-shim): render human-facing timestamps in the local timezone Split date() into date_utc() and date_local(), the latter resolving the system's local timezone through the tzfile crate ($TZ, then /etc/localtime, falling back to UTC when neither is readable). The timestamps Composer renders for humans -- the GitHub OAuth token note, the GitHub API rate limit reset time, the Perforce client spec fields and the "today" check of the show command -- now go through date_local(). PHP resolves its default timezone from the date.timezone ini setting, which Shirabe does not read, so date_default_timezone_get/set have no input left to model and are dropped from the shim and its callers. The resulting difference is recorded in docs/known-incompatibilities.md. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 17 +++++++ Cargo.toml | 1 + crates/shirabe-php-shim/Cargo.toml | 1 + crates/shirabe-php-shim/src/datetime.rs | 58 +++++++++++----------- crates/shirabe-php-shim/src/runtime.rs | 2 - .../src/command/complete_command.rs | 2 +- crates/shirabe/src/command/show_command.rs | 6 +-- crates/shirabe/src/console/application.rs | 20 +++----- crates/shirabe/src/util/github.rs | 6 +-- crates/shirabe/src/util/perforce.rs | 6 +-- crates/shirabe/tests/command/show_command_test.rs | 4 +- crates/shirabe/tests/common/bootstrap.rs | 3 -- docs/known-incompatibilities.md | 18 +++++++ 13 files changed, 83 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 880eb700..86c1c272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -2189,6 +2195,7 @@ dependencies = [ "tar", "tempfile", "twox-hash", + "tzfile", "zip", ] @@ -2682,6 +2689,16 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "tzfile" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f59c22c42a2537e4c7ad21a4007273bbc5bebed7f36bc93730a5780e22a4592e" +dependencies = [ + "byteorder", + "chrono", +] + [[package]] name = "unicode-general-category" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6f674171..75a88e65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ tokio = { version = "1.52.3", features = ["full"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] } twox-hash = "2.1.2" +tzfile = "0.1.3" url = "2.5.8" zip = "8.6.0" diff --git a/crates/shirabe-php-shim/Cargo.toml b/crates/shirabe-php-shim/Cargo.toml index 791cb805..cad9ad47 100644 --- a/crates/shirabe-php-shim/Cargo.toml +++ b/crates/shirabe-php-shim/Cargo.toml @@ -27,6 +27,7 @@ sha1.workspace = true sha2.workspace = true tar.workspace = true twox-hash.workspace = true +tzfile.workspace = true zip.workspace = true [dev-dependencies] diff --git a/crates/shirabe-php-shim/src/datetime.rs b/crates/shirabe-php-shim/src/datetime.rs index fd0348ff..28ec52af 100644 --- a/crates/shirabe-php-shim/src/datetime.rs +++ b/crates/shirabe-php-shim/src/datetime.rs @@ -1,11 +1,8 @@ -static DEFAULT_TIMEZONE: std::sync::Mutex> = std::sync::Mutex::new(None); - /// Parse the subset of the strtotime()/date_create() grammar that Composer actually emits. /// /// Supported: ISO8601/RFC3339 (`2023-01-15T12:34:56Z`, `...+00:00`), `Y-m-d H:i:s`, `Y-m-d`, -/// and `@`. Inputs without an explicit offset are interpreted as UTC, matching the -/// default timezone this shim assumes elsewhere. Anything else returns `None` rather than -/// guessing, mirroring PHP returning `false` on unrecognized input. +/// and `@`. Inputs without an explicit offset are interpreted as UTC. Anything else +/// returns `None` rather than guessing, mirroring PHP returning `false` on unrecognized input. fn parse_to_fixed(s: &str) -> Option> { let s = s.trim(); if s.is_empty() { @@ -126,34 +123,35 @@ pub fn microtime() -> f64 { duration.as_secs_f64() } -// PHP defaults to "UTC" when no default timezone has been configured. -pub fn date_default_timezone_get() -> String { - DEFAULT_TIMEZONE - .lock() - .unwrap() - .clone() - .unwrap_or_else(|| "UTC".to_string()) -} - -pub fn date_default_timezone_set(tz: &str) -> bool { - *DEFAULT_TIMEZONE.lock().unwrap() = Some(tz.to_string()); - true +/// PHP: `date()`, rendering in UTC. +pub fn date_utc(format: &str, timestamp: Option) -> String { + let timestamp = timestamp.unwrap_or_else(time); + let dt = chrono::DateTime::::from_timestamp(timestamp, 0) + .expect("date() timestamp out of range"); + dt.format(date_format_to_strftime(format)).to_string() } -pub fn date(format: &str, timestamp: Option) -> String { +/// PHP: `date()`, rendering in the system's local timezone. +pub fn date_local(format: &str, timestamp: Option) -> String { let timestamp = timestamp.unwrap_or_else(time); - // TODO(php-semantics): model the system default timezone. PHP `date()` renders in the default - // timezone (usually the system's local zone); without a timezone database only "UTC" can be - // resolved here, so on a non-UTC machine this diverges whenever the local date differs from - // the UTC date (e.g. daily 00:00-09:00 JST). Fixing this needs a timezone database (a new - // crate). Any named zone is rejected loudly rather than silently rendered in the wrong zone. - let tz = date_default_timezone_get(); - if tz != "UTC" { - panic!( - "date() with non-UTC default timezone {tz:?} is not supported (no timezone database)" - ); - } let dt = chrono::DateTime::::from_timestamp(timestamp, 0) .expect("date() timestamp out of range"); - dt.format(date_format_to_strftime(format)).to_string() + let tz = local_timezone(); + dt.with_timezone(&&tz) + .format(date_format_to_strftime(format)) + .to_string() +} + +/// The zone `$TZ` names, or the one `/etc/localtime` describes. Falls back to UTC when no tz +/// database is readable, as PHP does when `date.timezone` is unset. +fn local_timezone() -> tzfile::Tz { + #[cfg(unix)] + { + tzfile::Tz::local().unwrap_or_else(|_| tzfile::Tz::from(chrono::Utc)) + } + #[cfg(not(unix))] + { + // TODO(windows): `tzfile::Tz::local()` is Unix-only. + todo!() + } } diff --git a/crates/shirabe-php-shim/src/runtime.rs b/crates/shirabe-php-shim/src/runtime.rs index 6fba5774..fcd6a50f 100644 --- a/crates/shirabe-php-shim/src/runtime.rs +++ b/crates/shirabe-php-shim/src/runtime.rs @@ -67,8 +67,6 @@ pub fn function_exists(name: &str) -> bool { | "curl_multi_setopt" | "curl_share_init" | "curl_strerror" - | "date_default_timezone_get" - | "date_default_timezone_set" | "disk_free_space" | "exec" | "filter_var" diff --git a/crates/shirabe-symfony-console/src/command/complete_command.rs b/crates/shirabe-symfony-console/src/command/complete_command.rs index 8058d698..dc418178 100644 --- a/crates/shirabe-symfony-console/src/command/complete_command.rs +++ b/crates/shirabe-symfony-console/src/command/complete_command.rs @@ -276,7 +276,7 @@ impl Command for CompleteCommand { String::new(), format!( "{}", - shirabe_php_shim::date("Y-m-d H:i:s", None) + shirabe_php_shim::date_utc("Y-m-d H:i:s", None) ), "Input: (\"|\" indicates the cursor position)".to_string(), format!(" {}", completion_input.to_string()), diff --git a/crates/shirabe/src/command/show_command.rs b/crates/shirabe/src/command/show_command.rs index e5d5a1f2..f2246cbe 100644 --- a/crates/shirabe/src/command/show_command.rs +++ b/crates/shirabe/src/command/show_command.rs @@ -39,8 +39,8 @@ use indexmap::IndexMap; use shirabe_pcre::{CaptureKey, Preg}; use shirabe_php_shim::{ CmpOp, DATE_ATOM, InvalidArgumentException, LogicException, PhpMixed, UnexpectedValueException, - array_search, date, date_format_to_strftime, extension_loaded, impl_php_class, in_array_loose, - in_array_strict, php_regex, realpath, strtolower, version_compare, + array_search, date_format_to_strftime, date_local, extension_loaded, impl_php_class, + in_array_loose, in_array_strict, php_regex, realpath, strtolower, version_compare, }; use shirabe_semver::Semver; use shirabe_semver::constraint::AnyConstraint; @@ -1484,7 +1484,7 @@ impl ShowCommand { if release_date .format(date_format_to_strftime("Ymd")) .to_string() - == date("Ymd", None) + == date_local("Ymd", None) { return "today".to_string(); } diff --git a/crates/shirabe/src/console/application.rs b/crates/shirabe/src/console/application.rs index 3e4a746b..9a17868c 100644 --- a/crates/shirabe/src/console/application.rs +++ b/crates/shirabe/src/console/application.rs @@ -57,13 +57,12 @@ use crate::util::Silencer; use indexmap::IndexMap; use shirabe_php_shim::Catch as _; use shirabe_php_shim::{ - LogicException as ShimLogicException, PhpMixed, RuntimeException, bin2hex, chdir, - date_default_timezone_get, date_default_timezone_set, defined, dirname, disk_free_space, - extension_loaded, file_exists, file_get_contents, file_put_contents, function_exists, getcwd, - getmypid, glob, ini_set, is_array, is_dir, is_file, is_string, json_decode, - memory_get_peak_usage, memory_get_usage, microtime, php_regex, php_uname, posix_getuid, - random_bytes, realpath, restore_error_handler, round, str_replace, strpos, strtoupper, - sys_get_temp_dir, time, unlink, + LogicException as ShimLogicException, PhpMixed, RuntimeException, bin2hex, chdir, defined, + dirname, disk_free_space, extension_loaded, file_exists, file_get_contents, file_put_contents, + function_exists, getcwd, getmypid, glob, ini_set, is_array, is_dir, is_file, is_string, + json_decode, memory_get_peak_usage, memory_get_usage, microtime, php_regex, php_uname, + posix_getuid, random_bytes, realpath, restore_error_handler, round, str_replace, strpos, + strtoupper, sys_get_temp_dir, time, unlink, }; use shirabe_seld_json_lint::ParsingException; use shirabe_symfony_console::application::Application as BaseApplication; @@ -157,13 +156,6 @@ impl Application { ini_set("xdebug.scream", "0"); } - if function_exists("date_default_timezone_set") - && function_exists("date_default_timezone_get") - { - let tz = Silencer::call(|| Ok(date_default_timezone_get())).unwrap_or_default(); - date_default_timezone_set(&tz); - } - let io: std::rc::Rc> = std::rc::Rc::new(std::cell::RefCell::new(NullIO::new())); diff --git a/crates/shirabe/src/util/github.rs b/crates/shirabe/src/util/github.rs index 26756ed0..25a95e57 100644 --- a/crates/shirabe/src/util/github.rs +++ b/crates/shirabe/src/util/github.rs @@ -10,7 +10,7 @@ use crate::util::ProcessExecutor; use indexmap::IndexMap; use shirabe_pcre::{CaptureKey, Preg}; use shirabe_php_shim::Catch as _; -use shirabe_php_shim::{PhpMixed, date, in_array_loose, php_regex, stripos, strtolower}; +use shirabe_php_shim::{PhpMixed, date_local, in_array_loose, php_regex, stripos, strtolower}; #[derive(Debug)] pub struct GitHub { @@ -106,7 +106,7 @@ impl GitHub { note += &format!(" on {}", output.trim()); } } - note += &format!(" {}", date("Y-m-d Hi", None)); + note += &format!(" {}", date_local("Y-m-d Hi", None)); let (local_name, auth_name): (Option, String) = { let cfg = self.config.borrow(); @@ -309,7 +309,7 @@ impl GitHub { let ts: i64 = value.trim().parse().unwrap_or(0); rate_limit.insert( "reset".to_string(), - PhpMixed::String(date("Y-m-d H:i:s", Some(ts))), + PhpMixed::String(date_local("Y-m-d H:i:s", Some(ts))), ); } _ => {} diff --git a/crates/shirabe/src/util/perforce.rs b/crates/shirabe/src/util/perforce.rs index 1820936c..246e37f5 100644 --- a/crates/shirabe/src/util/perforce.rs +++ b/crates/shirabe/src/util/perforce.rs @@ -8,7 +8,7 @@ use crate::util::ProcessExecutor; use indexmap::IndexMap; use shirabe_pcre::Preg; use shirabe_php_shim::{ - Exception, PHP_EOL, PhpMixed, PhpResource, chdir, date, explode, fclose, feof, fgets, + Exception, PHP_EOL, PhpMixed, PhpResource, chdir, date_local, explode, fclose, feof, fgets, file_get_contents, fopen, fwrite, gethostname, json_decode, php_regex, str_replace_array, strcmp, strlen, strpos, strrpos, substr, time, trim, }; @@ -414,7 +414,7 @@ impl Perforce { spec, format!( "Update: {}{}{}", - date("Y/m/d H:i:s", None), + date_local("Y/m/d H:i:s", None), PHP_EOL, PHP_EOL ), @@ -422,7 +422,7 @@ impl Perforce { ); fwrite( spec, - format!("Access: {}{}", date("Y/m/d H:i:s", None), PHP_EOL), + format!("Access: {}{}", date_local("Y/m/d H:i:s", None), PHP_EOL), None, ); fwrite( diff --git a/crates/shirabe/tests/command/show_command_test.rs b/crates/shirabe/tests/command/show_command_test.rs index 47eae462..20dbf848 100644 --- a/crates/shirabe/tests/command/show_command_test.rs +++ b/crates/shirabe/tests/command/show_command_test.rs @@ -8,7 +8,7 @@ use serial_test::serial; use shirabe::package::Link; use shirabe::package::handle::PackageInterfaceHandle; use shirabe::repository::PlatformRepository; -use shirabe_php_shim::PhpMixed; +use shirabe_php_shim::{PhpMixed, date_local}; /// Build a `Vec<(PhpMixed, PhpMixed)>` command input from `(key, value)` pairs. fn input(pairs: Vec<(&str, PhpMixed)>) -> Vec<(PhpMixed, PhpMixed)> { @@ -1028,7 +1028,7 @@ fn test_self_and_package_combination() { match and prints \"this week\" whenever the local date differs from the UTC date \ (e.g. daily 00:00-09:00 JST); see TODO(php-semantics) in shirabe-php-shim datetime.rs"] fn test_self() { - let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + let today = date_local("Y-m-d", None); let _tear_down = init_temp_composer( Some(&serde_json::json!({ "name": "vendor/package", diff --git a/crates/shirabe/tests/common/bootstrap.rs b/crates/shirabe/tests/common/bootstrap.rs index a908bff2..9f667597 100644 --- a/crates/shirabe/tests/common/bootstrap.rs +++ b/crates/shirabe/tests/common/bootstrap.rs @@ -18,9 +18,6 @@ pub fn bootstrap() { ONCE.call_once(|| { // PHP: error_reporting(E_ALL) has no counterpart here. - // PHP: date_default_timezone_set(@date_default_timezone_get()); - shirabe_php_shim::date_default_timezone_set(&shirabe_php_shim::date_default_timezone_get()); - // PHP: require src/bootstrap.php and refresh vendor/composer/InstalledVersions.php. // TODO(php-runtime): port remaining bootstrap processes (the src/bootstrap.php include and // the InstalledVersions refresh are PHP autoload mechanics with no Rust counterpart yet). diff --git a/docs/known-incompatibilities.md b/docs/known-incompatibilities.md index 79ead94c..d90b926b 100644 --- a/docs/known-incompatibilities.md +++ b/docs/known-incompatibilities.md @@ -75,3 +75,21 @@ Reflection on objects a plugin creates itself works as usual. `ob_*()` functions work as usual in PHP, but cannot capture any output from Rust side. + + +## Misc. + +### Default Timezone + +PHP resolves the default timezone from the `date.timezone` INI setting, and +falls back to UTC when it is unset. Shirabe does not read php.ini: it uses the +system's local timezone instead and falls back to UTC when no tz database is +available. + +Only the date time for humans are affected, such as the reset time of the +GitHub API rate limit. The machine-readable time, e.g., timestamps written to +`composer.lock` or `vendor/composer/installed.json` are recorded in UTC in both +Composer and Shirabe. + +Plugins and scripts run in the PHP worker, where `date.timezone` is applied as +usual. -- cgit v1.3.1-4-g156e