From 3081b1509ba1b2c1108ea06ca3f05e9a794445e3 Mon Sep 17 00:00:00 2001 From: Terts Diepraam Date: Fri, 24 Mar 2023 11:51:38 +0100 Subject: [PATCH] touch: move from time to chrono This allows us to work with daylight savings time which is necessary to enable one of the tests. The leap second calculation and parsing are also ported over. A bump in the chrono version is necessary to use NaiveTime::MIN. --- Cargo.lock | 2 +- src/uu/touch/Cargo.toml | 2 +- src/uu/touch/src/touch.rs | 282 +++++++++++++----------------------- tests/by-util/test_touch.rs | 22 +-- 4 files changed, 107 insertions(+), 201 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c853c1fbff..70c39e185ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3163,9 +3163,9 @@ dependencies = [ name = "uu_touch" version = "0.0.17" dependencies = [ + "chrono", "clap", "filetime", - "time", "uucore", "windows-sys 0.42.0", ] diff --git a/src/uu/touch/Cargo.toml b/src/uu/touch/Cargo.toml index 1fad22c0282..7f3da6d29a3 100644 --- a/src/uu/touch/Cargo.toml +++ b/src/uu/touch/Cargo.toml @@ -17,7 +17,7 @@ path = "src/touch.rs" [dependencies] filetime = { workspace=true } clap = { workspace=true } -time = { workspace=true, features = ["parsing", "formatting", "local-offset", "macros"] } +chrono = { workspace=true } uucore = { workspace=true, features=["libc"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/src/uu/touch/src/touch.rs b/src/uu/touch/src/touch.rs index 31a5f8d7398..df9094ce965 100644 --- a/src/uu/touch/src/touch.rs +++ b/src/uu/touch/src/touch.rs @@ -6,23 +6,24 @@ // For the full copyright and license information, please view the LICENSE file // that was distributed with this source code. -// spell-checker:ignore (ToDO) filetime strptime utcoff strs datetime MMDDhhmm clapv PWSTR lpszfilepath hresult mktime YYYYMMDDHHMM YYMMDDHHMM DATETIME YYYYMMDDHHMMS subsecond +// spell-checker:ignore (ToDO) filetime datetime lpszfilepath mktime datetime subsecond datelike timelike +// spell-checker:ignore (FORMATS) MMDDhhmm YYYYMMDDHHMM YYMMDDHHMM YYYYMMDDHHMMS pub extern crate filetime; +use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveTime, TimeZone, Timelike, Utc}; use clap::builder::ValueParser; use clap::{crate_version, Arg, ArgAction, ArgGroup, Command}; -use filetime::*; +use filetime::{FileTime, set_file_times, set_symlink_file_times}; use std::ffi::OsString; use std::fs::{self, File}; use std::path::{Path, PathBuf}; -use time::macros::{format_description, offset, time}; -use time::Duration; use uucore::display::Quotable; use uucore::error::{FromIo, UError, UResult, USimpleError}; use uucore::{format_usage, help_about, help_usage, show}; const ABOUT: &str = help_about!("touch.md"); const USAGE: &str = help_usage!("touch.md"); + pub mod options { // Both SOURCES and sources are needed as we need to be able to refer to the ArgGroup. pub static SOURCES: &str = "sources"; @@ -41,27 +42,31 @@ pub mod options { static ARG_FILES: &str = "files"; -// Convert a date/time to a date with a TZ offset -fn to_local(tm: time::PrimitiveDateTime) -> time::OffsetDateTime { - let offset = match time::OffsetDateTime::now_local() { - Ok(lo) => lo.offset(), - Err(e) => { - panic!("error: {e}"); - } - }; - tm.assume_offset(offset) -} - -// Convert a date/time with a TZ offset into a FileTime -fn local_dt_to_filetime(dt: time::OffsetDateTime) -> FileTime { - FileTime::from_unix_time(dt.unix_timestamp(), dt.nanosecond()) +mod format { + pub(crate) const POSIX_LOCALE: &str = "%a %b %e %H:%M:%S %Y"; + pub(crate) const ISO_8601: &str = "%Y-%m-%d"; + // "%Y%m%d%H%M.%S" 15 chars + pub(crate) const YYYYMMDDHHMM_DOT_SS: &str = "%Y%m%d%H%M.%S"; + // "%Y-%m-%d %H:%M:%S.%SS" 12 chars + pub(crate) const YYYYMMDDHHMMSS: &str = "%Y-%m-%d %H:%M:%S.%f"; + // "%Y-%m-%d %H:%M:%S" 12 chars + pub(crate) const YYYYMMDDHHMMS: &str = "%Y-%m-%d %H:%M:%S"; + // "%Y-%m-%d %H:%M" 12 chars + // Used for example in tests/touch/no-rights.sh + pub(crate) const YYYY_MM_DD_HH_MM: &str = "%Y-%m-%d %H:%M"; + // "%Y%m%d%H%M" 12 chars + pub(crate) const YYYYMMDDHHMM: &str = "%Y%m%d%H%M"; + // "%Y-%m-%d %H:%M +offset" + // Used for example in tests/touch/relative.sh + pub(crate) const YYYYMMDDHHMM_OFFSET: &str = "%Y-%m-%d %H:%M %z"; } -// Convert a date/time, considering that the input is in UTC time -// Used for touch -d 1970-01-01 18:43:33.023456789 for example -fn dt_to_filename(tm: time::PrimitiveDateTime) -> FileTime { - let dt = tm.assume_offset(offset!(UTC)); - local_dt_to_filetime(dt) +/// Convert a DateTime with a TZ offset into a FileTime +/// +/// The DateTime is converted into a unix timestamp from which the FileTime is +/// constructed. +fn dt_to_filetime(dt: DateTime) -> FileTime { + FileTime::from_unix_time(dt.timestamp(), dt.timestamp_subsec_nanos()) } #[uucore::main] @@ -86,7 +91,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } else if let Some(current) = matches.get_one::(options::sources::CURRENT) { parse_timestamp(current)? } else { - local_dt_to_filetime(time::OffsetDateTime::now_local().unwrap()) + dt_to_filetime(Local::now()) }; (timestamp, timestamp) }; @@ -163,7 +168,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { if matches.get_flag(options::NO_DEREF) { set_symlink_file_times(path, atime, mtime) } else { - filetime::set_file_times(path, atime, mtime) + set_file_times(path, atime, mtime) } .map_err_context(|| format!("setting times of {}", path.quote()))?; } @@ -275,64 +280,6 @@ fn stat(path: &Path, follow: bool) -> UResult<(FileTime, FileTime)> { )) } -const POSIX_LOCALE_FORMAT: &[time::format_description::FormatItem] = format_description!( - "[weekday repr:short] [month repr:short] [day padding:space] \ - [hour]:[minute]:[second] [year]" -); - -const ISO_8601_FORMAT: &[time::format_description::FormatItem] = - format_description!("[year]-[month]-[day]"); - -// "%Y%m%d%H%M.%S" 15 chars -const YYYYMMDDHHMM_DOT_SS_FORMAT: &[time::format_description::FormatItem] = format_description!( - "[year repr:full][month repr:numerical padding:zero]\ - [day][hour][minute].[second]" -); - -// "%Y-%m-%d %H:%M:%S.%SS" 12 chars -const YYYYMMDDHHMMSS_FORMAT: &[time::format_description::FormatItem] = format_description!( - "[year repr:full]-[month repr:numerical padding:zero]-\ - [day] [hour]:[minute]:[second].[subsecond]" -); - -// "%Y-%m-%d %H:%M:%S" 12 chars -const YYYYMMDDHHMMS_FORMAT: &[time::format_description::FormatItem] = format_description!( - "[year repr:full]-[month repr:numerical padding:zero]-\ - [day] [hour]:[minute]:[second]" -); - -// "%Y-%m-%d %H:%M" 12 chars -// Used for example in tests/touch/no-rights.sh -const YYYY_MM_DD_HH_MM_FORMAT: &[time::format_description::FormatItem] = format_description!( - "[year repr:full]-[month repr:numerical padding:zero]-\ - [day] [hour]:[minute]" -); - -// "%Y%m%d%H%M" 12 chars -const YYYYMMDDHHMM_FORMAT: &[time::format_description::FormatItem] = format_description!( - "[year repr:full][month repr:numerical padding:zero]\ - [day][hour][minute]" -); - -// "%y%m%d%H%M.%S" 13 chars -const YYMMDDHHMM_DOT_SS_FORMAT: &[time::format_description::FormatItem] = format_description!( - "[year repr:last_two padding:none][month][day]\ - [hour][minute].[second]" -); - -// "%y%m%d%H%M" 10 chars -const YYMMDDHHMM_FORMAT: &[time::format_description::FormatItem] = format_description!( - "[year repr:last_two padding:none][month padding:zero][day padding:zero]\ - [hour repr:24 padding:zero][minute padding:zero]" -); - -// "%Y-%m-%d %H:%M +offset" -// Used for example in tests/touch/relative.sh -const YYYYMMDDHHMM_OFFSET_FORMAT: &[time::format_description::FormatItem] = format_description!( - "[year]-[month]-[day] [hour repr:24]:[minute] \ - [offset_hour sign:mandatory][offset_minute]" -); - fn parse_date(s: &str) -> UResult { // This isn't actually compatible with GNU touch, but there doesn't seem to // be any simple specification for what format this parameter allows and I'm @@ -348,59 +295,50 @@ fn parse_date(s: &str) -> UResult { // Tue Dec 3 ... // ("%c", POSIX_LOCALE_FORMAT), // - if let Ok(parsed) = time::PrimitiveDateTime::parse(s, &POSIX_LOCALE_FORMAT) { - return Ok(local_dt_to_filetime(to_local(parsed))); + if let Ok(parsed) = Local.datetime_from_str(s, &format::POSIX_LOCALE) { + return Ok(dt_to_filetime(parsed)); } // Also support other formats found in the GNU tests like // in tests/misc/stat-nanoseconds.sh // or tests/touch/no-rights.sh for fmt in [ - YYYYMMDDHHMMS_FORMAT, - YYYYMMDDHHMMSS_FORMAT, - YYYY_MM_DD_HH_MM_FORMAT, - YYYYMMDDHHMM_OFFSET_FORMAT, + format::YYYYMMDDHHMMS, + format::YYYYMMDDHHMMSS, + format::YYYY_MM_DD_HH_MM, + format::YYYYMMDDHHMM_OFFSET, ] { - if let Ok(parsed) = time::PrimitiveDateTime::parse(s, &fmt) { - return Ok(dt_to_filename(parsed)); + if let Ok(parsed) = Utc.datetime_from_str(s, fmt) { + return Ok(dt_to_filetime(parsed)); } } // "Equivalent to %Y-%m-%d (the ISO 8601 date format). (C99)" // ("%F", ISO_8601_FORMAT), - if let Ok(parsed) = time::Date::parse(s, &ISO_8601_FORMAT) { - return Ok(local_dt_to_filetime(to_local( - time::PrimitiveDateTime::new(parsed, time!(00:00)), - ))); + if let Ok(parsed_date) = NaiveDate::parse_from_str(s, format::ISO_8601) { + let parsed = Local + .from_local_datetime(&parsed_date.and_time(NaiveTime::MIN)) + .unwrap(); + return Ok(dt_to_filetime(parsed)); } // "@%s" is "The number of seconds since the Epoch, 1970-01-01 00:00:00 +0000 (UTC). (TZ) (Calculated from mktime(tm).)" if s.bytes().next() == Some(b'@') { if let Ok(ts) = &s[1..].parse::() { - // Don't convert to local time in this case - seconds since epoch are not time-zone dependent - return Ok(local_dt_to_filetime( - time::OffsetDateTime::from_unix_timestamp(*ts).unwrap(), - )); + return Ok(FileTime::from_unix_time(*ts, 0)); } } // Relative day, like "today", "tomorrow", or "yesterday". match s { "now" | "today" => { - let now_local = time::OffsetDateTime::now_local().unwrap(); - return Ok(local_dt_to_filetime(now_local)); + return Ok(dt_to_filetime(Local::now())); } "tomorrow" => { - let duration = time::Duration::days(1); - let now_local = time::OffsetDateTime::now_local().unwrap(); - let diff = now_local.checked_add(duration).unwrap(); - return Ok(local_dt_to_filetime(diff)); + return Ok(dt_to_filetime(Local::now() + Duration::days(1))); } "yesterday" => { - let duration = time::Duration::days(1); - let now_local = time::OffsetDateTime::now_local().unwrap(); - let diff = now_local.checked_sub(duration).unwrap(); - return Ok(local_dt_to_filetime(diff)); + return Ok(dt_to_filetime(Local::now() - Duration::days(1))); } _ => {} } @@ -411,47 +349,47 @@ fn parse_date(s: &str) -> UResult { // TODO Add support for times without spaces like "-1hour". let tokens: Vec<&str> = s.split_whitespace().collect(); let maybe_duration = match &tokens[..] { - [num_str, "fortnight" | "fortnights"] => num_str - .parse::() - .ok() - .map(|n| time::Duration::weeks(2 * n)), - ["fortnight" | "fortnights"] => Some(time::Duration::weeks(2)), - [num_str, "week" | "weeks"] => num_str.parse::().ok().map(time::Duration::weeks), - ["week" | "weeks"] => Some(time::Duration::weeks(1)), - [num_str, "day" | "days"] => num_str.parse::().ok().map(time::Duration::days), - ["day" | "days"] => Some(time::Duration::days(1)), - [num_str, "hour" | "hours"] => num_str.parse::().ok().map(time::Duration::hours), - ["hour" | "hours"] => Some(time::Duration::hours(1)), + [num_str, "fortnight" | "fortnights"] => { + num_str.parse::().ok().map(|n| Duration::weeks(2 * n)) + } + ["fortnight" | "fortnights"] => Some(Duration::weeks(2)), + [num_str, "week" | "weeks"] => num_str.parse::().ok().map(Duration::weeks), + ["week" | "weeks"] => Some(Duration::weeks(1)), + [num_str, "day" | "days"] => num_str.parse::().ok().map(Duration::days), + ["day" | "days"] => Some(Duration::days(1)), + [num_str, "hour" | "hours"] => num_str.parse::().ok().map(Duration::hours), + ["hour" | "hours"] => Some(Duration::hours(1)), [num_str, "minute" | "minutes" | "min" | "mins"] => { - num_str.parse::().ok().map(time::Duration::minutes) + num_str.parse::().ok().map(Duration::minutes) } - ["minute" | "minutes" | "min" | "mins"] => Some(time::Duration::minutes(1)), + ["minute" | "minutes" | "min" | "mins"] => Some(Duration::minutes(1)), [num_str, "second" | "seconds" | "sec" | "secs"] => { - num_str.parse::().ok().map(time::Duration::seconds) + num_str.parse::().ok().map(Duration::seconds) } - ["second" | "seconds" | "sec" | "secs"] => Some(time::Duration::seconds(1)), + ["second" | "seconds" | "sec" | "secs"] => Some(Duration::seconds(1)), _ => None, }; + if let Some(duration) = maybe_duration { - let now_local = time::OffsetDateTime::now_local().unwrap(); - let diff = now_local.checked_add(duration).unwrap(); - return Ok(local_dt_to_filetime(diff)); + return Ok(dt_to_filetime(Local::now() + duration)); } Err(USimpleError::new(1, format!("Unable to parse date: {s}"))) } fn parse_timestamp(s: &str) -> UResult { - // TODO: handle error - let now = time::OffsetDateTime::now_utc(); - - let (mut format, mut ts) = match s.chars().count() { - 15 => (YYYYMMDDHHMM_DOT_SS_FORMAT, s.to_owned()), - 12 => (YYYYMMDDHHMM_FORMAT, s.to_owned()), - 13 => (YYMMDDHHMM_DOT_SS_FORMAT, s.to_owned()), - 10 => (YYMMDDHHMM_FORMAT, s.to_owned()), - 11 => (YYYYMMDDHHMM_DOT_SS_FORMAT, format!("{}{}", now.year(), s)), - 8 => (YYYYMMDDHHMM_FORMAT, format!("{}{}", now.year(), s)), + use format::*; + + let current_year = || Local::now().year(); + + let (format, ts) = match s.chars().count() { + 15 => (YYYYMMDDHHMM_DOT_SS, s.to_owned()), + 12 => (YYYYMMDDHHMM, s.to_owned()), + // If we don't add "20", we have insufficient information to parse + 13 => (YYYYMMDDHHMM_DOT_SS, format!("20{}", s)), + 10 => (YYYYMMDDHHMM, format!("20{}", s)), + 11 => (YYYYMMDDHHMM_DOT_SS, format!("{}{}", current_year(), s)), + 8 => (YYYYMMDDHHMM, format!("{}{}", current_year(), s)), _ => { return Err(USimpleError::new( 1, @@ -459,54 +397,34 @@ fn parse_timestamp(s: &str) -> UResult { )) } }; - // workaround time returning Err(TryFromParsed(InsufficientInformation)) for year w/ - // repr:last_two - // https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=1ccfac7c07c5d1c7887a11decf0e1996 - if s.chars().count() == 10 { - format = YYYYMMDDHHMM_FORMAT; - ts = "20".to_owned() + &ts; - } else if s.chars().count() == 13 { - format = YYYYMMDDHHMM_DOT_SS_FORMAT; - ts = "20".to_owned() + &ts; - } - let leap_sec = if (format == YYYYMMDDHHMM_DOT_SS_FORMAT || format == YYMMDDHHMM_DOT_SS_FORMAT) - && ts.ends_with(".60") - { - // Work around to disable leap seconds - // Used in gnu/tests/touch/60-seconds - ts = ts.replace(".60", ".59"); - true - } else { - false - }; - - let tm = time::PrimitiveDateTime::parse(&ts, &format) + let mut local = chrono::Local + .datetime_from_str(&ts, &format) .map_err(|_| USimpleError::new(1, format!("invalid date ts format {}", ts.quote())))?; - let mut local = to_local(tm); - if leap_sec { - // We are dealing with a leap second, add it - local = local.saturating_add(Duration::SECOND); + + // Chrono caps seconds at 59, but 60 is valid. It might be a leap second + // or wrap to the next minute. But that doesn't really matter, because we + // only care about the timestamp anyway. + // Tested in gnu/tests/touch/60-seconds + if local.second() == 59 && ts.ends_with(".60") { + local += Duration::seconds(1); } - let ft = local_dt_to_filetime(local); - - // // We have to check that ft is valid time. Due to daylight saving - // // time switch, local time can jump from 1:59 AM to 3:00 AM, - // // in which case any time between 2:00 AM and 2:59 AM is not valid. - // // Convert back to local time and see if we got the same value back. - // let ts = time::Timespec { - // sec: ft.unix_seconds(), - // nsec: 0, - // }; - // let tm2 = time::at(ts); - // if tm.tm_hour != tm2.tm_hour { - // return Err(USimpleError::new( - // 1, - // format!("invalid date format {}", s.quote()), - // )); - // } - - Ok(ft) + + // We have to check that ft is valid time. Due to daylight saving + // time switch, local time can jump from 1:59 AM to 3:00 AM, + // in which case any time between 2:00 AM and 2:59 AM is not valid. + // If we are within this jump, chrono assumes takes the offset from before + // the jump. If we then jump forward an hour, we get the new corrected offset. + // Jumping back will then now correctly take the jump into account. + let local2 = local + Duration::hours(1) - Duration::hours(1); + if local.hour() != local2.hour() { + return Err(USimpleError::new( + 1, + format!("invalid date format {}", s.quote()), + )); + } + + Ok(dt_to_filetime(local)) } // TODO: this may be a good candidate to put in fsext.rs diff --git a/tests/by-util/test_touch.rs b/tests/by-util/test_touch.rs index 0c0a0b0420c..290896ebd74 100644 --- a/tests/by-util/test_touch.rs +++ b/tests/by-util/test_touch.rs @@ -9,8 +9,8 @@ extern crate touch; use self::touch::filetime::{self, FileTime}; -extern crate time; -use time::macros::format_description; +extern crate chrono; +use chrono::TimeZone; use crate::common::util::{AtPath, TestScenario}; use std::fs::remove_file; @@ -37,21 +37,9 @@ fn set_file_times(at: &AtPath, path: &str, atime: FileTime, mtime: FileTime) { filetime::set_file_times(at.plus_as_string(path), atime, mtime).unwrap(); } -// Adjusts for local timezone fn str_to_filetime(format: &str, s: &str) -> FileTime { - let format_description = match format { - "%y%m%d%H%M" => format_description!("[year repr:last_two][month][day][hour][minute]"), - "%y%m%d%H%M.%S" => { - format_description!("[year repr:last_two][month][day][hour][minute].[second]") - } - "%Y%m%d%H%M" => format_description!("[year][month][day][hour][minute]"), - "%Y%m%d%H%M.%S" => format_description!("[year][month][day][hour][minute].[second]"), - _ => panic!("unexpected dt format"), - }; - let tm = time::PrimitiveDateTime::parse(s, &format_description).unwrap(); - let d = time::OffsetDateTime::now_utc(); - let offset_dt = tm.assume_offset(d.offset()); - FileTime::from_unix_time(offset_dt.unix_timestamp(), tm.nanosecond()) + let tm = chrono::Utc.datetime_from_str(s, format).unwrap(); + FileTime::from_unix_time(tm.timestamp(), tm.timestamp_subsec_nanos()) } #[test] @@ -626,7 +614,7 @@ fn test_touch_mtime_dst_succeeds() { } #[test] -#[ignore = "not implemented"] +// #[ignore = "not implemented"] fn test_touch_mtime_dst_fails() { let (_at, mut ucmd) = at_and_ucmd!(); let file = "test_touch_set_mtime_dst_fails";