time: Implement function returning the local UTC offset

As was previously possible in time 0.1 and as requested by multiple people in #197, I would like to add a function allowing the user to obtain their local UTC offset. This information would necessarily come from the operating system.

I do not have sufficient experience with C, let alone interaction with system APIs, so I am requesting this be implemented by those that know better than myself. libc and winapi should suffice as conditional dependencies to cover Linux, MacOS, and Windows. Support for Redox would be nice, but is not essential.

When implementing, the following signature should be used.

impl UtcOffset {
    fn local() -> Self;
}

I can put together docs and tests; it’s the implementation itself where assistance is needed. The relevant file is src/utc_offset.rs. Replacing #![forbid(unsafe)] with #![deny(unsafe)] and #[allow]ing the relevant bits is, of course, allowed in this situation.

If you’re interested, please leave a comment!

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 1
  • Comments: 48 (23 by maintainers)

Commits related to this issue

Most upvoted comments

@hroi Off chance you could write a snippet using that API ^^?

This is what I now have for Unix:

pub fn local_offset_at(datetime: PrimitiveDateTime) -> Self {
    extern "C" {
        fn localtime_r(timer: *const i64, result: *mut Tm) -> *mut Tm;
    }

    #[repr(C)]
    struct Tm {
        tm_sec: i32,
        tm_min: i32,
        tm_hour: i32,
        tm_mday: i32,
        tm_mon: i32,
        tm_year: i32,
        tm_wday: i32,
        tm_yday: i32,
        tm_isdst: i32,
        tm_gmtoff: i32,
        tm_zone: *const i8,
    }

    let mut result = Tm {
        tm_sec: 0,
        tm_min: 0,
        tm_hour: 0,
        tm_mday: 0,
        tm_mon: 0,
        tm_year: 0,
        tm_wday: 0,
        tm_yday: 0,
        tm_isdst: 0,
        tm_gmtoff: 0,
        tm_zone: &0,
    };

    // Safety: We are calling a system API, which mutates the `result`
    // variable. If a null pointer is returned, an error occured and we
    // should default to UTC.
    #[allow(unsafe_code)]
    match unsafe { localtime_r(&datetime.timestamp(), &mut result).as_ref() } {
        Some(local) => {
            let offset = local.tm_gmtoff;
            debug_assert!(-86_400 < offset && offset < 86_400);
            Self::seconds(offset)
        },
        None => Self::UTC,
    }
}

I’m also providing the following helper function for anyone that just wants the current offset.

pub fn current_local_offset() -> Self {
    Self::local_offset_at(PrimitiveDateTime::now())
}

Some issues I see with this approach:


On Unix, local time offset depends on the UTC time you want to convert from (to account for DST). That’s why localtime and localtime_r have a timer argument. BTW timezone doesn’t account for DST.

Example showing this

#include <stdio.h>
#include <time.h>

int main() {
	time_t t;
	struct tm *local;

	t = 0;
	local = localtime(&t);
	printf("%ld\n", timezone);
	printf("%d\n", local->tm_hour);

	t = 15638400;
	local = localtime(&t);
	printf("%ld\n", timezone);
	printf("%d\n", local->tm_hour);

	return 0;
}

In PDT this yields:

28800
16
28800
17

Solution: change the signature to

fn local(datetime: PrimitiveDateTime) -> UtcOffset

but see below.


localtime and localtime_r can fail with EOVERFLOW. time 0.1 panics in this case: https://github.com/time-rs/time/blob/6d96f0c7dbf9c430e10d90e41d9bea52be4d685d/src/sys.rs#L434, but IMO this is a hidden attack vector.

Solution: return a Result:

fn local(datetime: PrimitiveDateTime) -> Result<UtcOffset, ConversionRangeError>

or

fn local(datetime: PrimitiveDateTime) -> io::Result<UtcOffset>

localtime isn’t thread-safe. localtime_r is, but isn’t required to update new timezone info from system. See chronotope/chrono#272.

Solution: use localtime_r instead of localtime, and call tzset() before each call to localtime_r.


localtime and localtime_r: https://pubs.opengroup.org/onlinepubs/9699919799/functions/localtime.html

@jhpratt Thanks for the info. In that case I think I will change my own code to not expose DST info. This way I can at least start upgrading to time 0.2. Thanks!

@YorickPeterse That functionality has not been added. It likely will be eventually, but the edge cases are plentiful.

The simplest way to check this now is likely to use UtcOffset::local_offset_at(OffsetDateTime::unix_epoch()) and UtcOffset::local_offset_at(OffsetDateTime::unix_epoch() + 180.days()), which will obtain the “standard” and “summer” offsets. Note that either can be larger, as the southern hemisphere has DST in January, not July. You could then check UtcOffset::current_local_offset() and compare that to see which one it is.

This will likely work for a majority of cases. However, the dates for DST change on occasion (see: EU likely dropping it next year), and some countries like Morocco have even intricate rules revolving around Ramadan. To work around that, short of having full tzdb support, you’d need yet another syscall to probe a date such that one of them is guaranteed to not be Ramadan.

I’m fairly certain there are even crazier cases than this, so at least for now, it’ll stay out of the time crate.

I wrote one with libc and is POSIX compatible. Feel free to use it in any way. Note tm_gmtoff is an extension and doesn’t seem to exist on Solaris.

EDIT: minor code fix

use core::convert::TryInto;
use core::{mem, ptr};
use time::{date, time, PrimitiveDateTime, UtcOffset};

fn localtime(timestamp: i64) -> Option<libc::tm> {
    extern "C" {
        fn tzset();
    }

    let timestamp: libc::time_t = match timestamp.try_into() {
        Ok(timestamp) => timestamp,
        Err(_) => return None,
    };

    #[allow(unsafe_code)]
    unsafe {
        // Safety: Plain old data.
        let mut result: libc::tm = mem::zeroed();

        // Update timezone information from system. localtime_r does not
        // do this for us.
        //
        // Safety: tzset is MT-Safe.
        tzset();
        // Safety: We are calling a system API, which mutates the `result`
        // variable. If a null pointer is returned, an error occured and we
        // should default to UTC.
        let result_ptr = libc::localtime_r(&timestamp, &mut result);

        if result_ptr != ptr::null_mut() {
            Some(result)
        } else {
            None
        }
    }
}

pub fn local_offset_at(datetime: PrimitiveDateTime) -> UtcOffset {
    let timestamp = datetime.timestamp();

    let result = match localtime(timestamp) {
        Some(result) => result,
        None => return UtcOffset::UTC,
    };

    // `tm_gmtoff` extension
    #[cfg(not(target_os = "solaris"))]
    {
        if let Ok(offset) = result.tm_gmtoff.try_into() {
            UtcOffset::seconds(offset)
        } else {
            UtcOffset::UTC
        }
    }
    // No `tm_gmtoff` extension
    #[cfg(target_os = "solaris")]
    {
        use time::{Date, Time};

        let mut result = result;
        if result.tm_sec > 59 {
            // Time doesn't accept leap seconds, so we ignore them.
            //
            // Alternatively we could add them to the timestamp. But we'll
            // end up in the next minute, which is worse than staying at
            // the last second of this minute.
            result.tm_sec = 59;
        }
        let time = match Time::try_from_hms(
            result.tm_hour as u8,
            result.tm_min as u8,
            result.tm_sec as u8,
        ) {
            Ok(time) => time,
            Err(_) => return UtcOffset::UTC,
        };
        let date = match Date::try_from_yo(result.tm_year + 1900, result.tm_yday as u16 + 1) {
            Ok(date) => date,
            Err(_) => return UtcOffset::UTC,
        };
        let local_timestamp = PrimitiveDateTime::new(date, time).timestamp();

        let offset = local_timestamp - timestamp;
        match offset.try_into() {
            Ok(offset) => UtcOffset::seconds(offset),
            Err(_) => UtcOffset::UTC,
        }
    }
}

You can see how it behaves with DST and changing timezone:

Code

fn main() {
    use std::io::Read;
    println!(
        "Offset on 2020-01-01: {:?}",
        local_offset_at(PrimitiveDateTime::new(date!(2020 - 01 - 01), time!(0:00)))
    );
    println!(
        "Offset on 2020-07-01: {:?}",
        local_offset_at(PrimitiveDateTime::new(date!(2020 - 07 - 01), time!(0:00)))
    );
    drop(std::io::stdin().lock().read(&mut vec![0])); // You can change your system timezone here
    println!(
        "Offset on 2020-01-01: {:?}",
        local_offset_at(PrimitiveDateTime::new(date!(2020 - 01 - 01), time!(0:00)))
    );
    println!(
        "Offset on 2020-07-01: {:?}",
        local_offset_at(PrimitiveDateTime::new(date!(2020 - 07 - 01), time!(0:00)))
    );
}


(NB: everything after Y2038 will be UtcOffset::UTC on 32bit system since you don’t want to return Err.)

On windows.

#[cfg(target_family = "windows")]
impl UtcOffset {
    #[allow(unsafe_code)]
    pub fn local() -> Self {
        use std::mem::MaybeUninit;
        use winapi::um::{timezoneapi, winnt};
        unsafe {
            let mut tz_info = MaybeUninit::uninit();
            let result = timezoneapi::GetDynamicTimeZoneInformation(tz_info.as_mut_ptr());

            // The current bias for local time translation on this computer, in minutes.
            // The bias is the difference, in minutes, between Coordinated Universal Time
            // (UTC) and local time. All translations between UTC and local time are based
            // on the following formula:
            //
            // UTC = local time + bias
            let bias_mins = match result {
                // Daylight saving time is not used in the current time zone,
                // because there are no transition dates.
                winnt::TIME_ZONE_ID_UNKNOWN => tz_info.assume_init().Bias,
                // The system is operating in the range covered by the
                // StandardDate member of the DYNAMIC_TIME_ZONE_INFORMATION structure.
                winnt::TIME_ZONE_ID_STANDARD => {
                    tz_info.assume_init().Bias + tz_info.assume_init().StandardBias
                }
                // The system is operating in the range covered by the DaylightDate
                // member of the DYNAMIC_TIME_ZONE_INFORMATION structure
                winnt::TIME_ZONE_ID_DAYLIGHT => {
                    tz_info.assume_init().Bias + tz_info.assume_init().DaylightBias
                }
                // call failed somehow, return UTC
                _ => return Self::UTC,
            };
            UtcOffset {
                seconds: bias_mins * -60,
            }
        }
    }
}

Return values, currently (Jan 2020):

  • Auckland, NZ: 46800 (+13 hours)
  • Copenhagen, DK: 3600 (+1 hour)
  • Reykjavik, IS: 0
  • Bangkok, TH: 25200 (+7 hours)
  • Pacific time: -28800 (-8 hours)