13 Commits

Author SHA1 Message Date
Sylvestre Ledru b83d7a953c builder: apply fixed offset before relative adjustments
A fixed offset (e.g. the "UTC" keyword) was applied after relative
items, so "1970/01/01 UTC N seconds" added N seconds to a local
wall-clock that could drift across a DST boundary before being
re-anchored at the offset, yielding an instant off by the DST gap.
Anchor the offset first, then apply relative items.

Fixes uutils/coreutils#12555.
2026-06-09 08:51:02 +02:00
renovate[bot] 4b412e03d2 chore(deps): update codecov/codecov-action action to v7 2026-06-07 09:06:00 +02:00
renovate[bot] c312311cf0 chore(deps): update rust crate libfuzzer-sys to v0.4.13 2026-06-05 07:21:52 +02:00
renovate[bot] 672a2acbc4 chore(deps): update rust crate jiff to v0.2.28 2026-05-29 08:20:02 +02:00
renovate[bot] 665eb95a13 chore(deps): update rust crate jiff to v0.2.27 2026-05-26 17:56:31 +02:00
renovate[bot] 0371374e7e chore(deps): update rust crate jiff to v0.2.25 2026-05-25 08:24:30 +02:00
xtqqczze 1129eeaeaf actions: add security audit workflow 2026-05-24 15:27:22 +02:00
renovate[bot] 400969043d chore(deps): update rust crate codspeed-criterion-compat to v4.7.0 2026-05-23 07:07:59 +02:00
renovate[bot] d464f3a2b1 chore(deps): update rust crate codspeed-criterion-compat to v4.6.0 2026-05-18 11:45:58 +02:00
renovate[bot] 025feb86f8 chore(deps): update rust crate winnow to v1.0.3 2026-05-15 07:18:53 +02:00
renovate[bot] 53ed79bcfc chore(deps): update rust crate jiff to v0.2.24 2026-04-24 07:12:33 +02:00
renovate[bot] d5bd1e8e4a chore(deps): update rust crate winnow to v1.0.2 2026-04-21 14:52:19 +02:00
renovate[bot] 7e9a8b502c chore(deps): update rust crate codspeed-criterion-compat to v4.5.0 2026-04-17 16:08:40 +02:00
6 changed files with 83 additions and 55 deletions
+15
View File
@@ -0,0 +1,15 @@
name: Security audit
on:
schedule:
- cron: "0 0 * * *"
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -131,7 +131,7 @@ jobs:
grcov . --binary-path="${COVERAGE_REPORT_DIR}" --output-type lcov --output-path "${COVERAGE_REPORT_FILE}" --branch --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()" --source-dir . grcov . --binary-path="${COVERAGE_REPORT_DIR}" --output-type lcov --output-path "${COVERAGE_REPORT_FILE}" --branch --ignore build.rs --ignore "vendor/*" --ignore "/*" --ignore "[a-zA-Z]:/*" --excl-br-line "^\s*((debug_)?assert(_eq|_ne)?!|#\[derive\()" --source-dir .
echo "report=${COVERAGE_REPORT_FILE}" >> $GITHUB_OUTPUT echo "report=${COVERAGE_REPORT_FILE}" >> $GITHUB_OUTPUT
- name: Upload coverage results (to Codecov.io) - name: Upload coverage results (to Codecov.io)
uses: codecov/codecov-action@v6 uses: codecov/codecov-action@v7
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
files: ${{ steps.coverage.outputs.report }} files: ${{ steps.coverage.outputs.report }}
Generated
+17 -26
View File
@@ -138,9 +138,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "codspeed" name = "codspeed"
version = "4.4.1" version = "4.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b684e94583e85a5ca7e1a6454a89d76a5121240f2fb67eb564129d9bafdb9db0" checksum = "57af92d1db7f6871b7e82c79cd87f2501db66f36b0eab924be6ea83dd6b2f3f3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cc", "cc",
@@ -156,9 +156,9 @@ dependencies = [
[[package]] [[package]]
name = "codspeed-criterion-compat" name = "codspeed-criterion-compat"
version = "4.4.1" version = "4.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e65444156eb73ad7f57618188f8d4a281726d133ef55b96d1dcff89528609ab" checksum = "1d31ae2e9ab23c29fa13bdfa06d012524176f5c0f4e25ec262cd829d947ebc5e"
dependencies = [ dependencies = [
"clap", "clap",
"codspeed", "codspeed",
@@ -169,9 +169,9 @@ dependencies = [
[[package]] [[package]]
name = "codspeed-criterion-compat-walltime" name = "codspeed-criterion-compat-walltime"
version = "4.4.1" version = "4.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96389aaa4bbb872ea4924dc0335b2bb181bcf28d6eedbe8fea29afcc5bde36a6" checksum = "cc8605e40bab5114dcb0f76268e18880082b5798dec10757b5b58d2c3bbc7a1c"
dependencies = [ dependencies = [
"anes", "anes",
"cast", "cast",
@@ -201,7 +201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"windows-sys 0.59.0", "windows-sys",
] ]
[[package]] [[package]]
@@ -364,7 +364,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [ dependencies = [
"hermit-abi", "hermit-abi",
"libc", "libc",
"windows-sys 0.61.2", "windows-sys",
] ]
[[package]] [[package]]
@@ -384,9 +384,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "jiff" name = "jiff"
version = "0.2.23" version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
dependencies = [ dependencies = [
"jiff-static", "jiff-static",
"jiff-tzdb-platform", "jiff-tzdb-platform",
@@ -394,14 +394,14 @@ dependencies = [
"portable-atomic", "portable-atomic",
"portable-atomic-util", "portable-atomic-util",
"serde_core", "serde_core",
"windows-sys 0.61.2", "windows-link",
] ]
[[package]] [[package]]
name = "jiff-static" name = "jiff-static"
version = "0.2.23" version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -498,7 +498,7 @@ dependencies = [
"jiff", "jiff",
"num-traits", "num-traits",
"rstest", "rstest",
"winnow 1.0.1", "winnow 1.0.3",
] ]
[[package]] [[package]]
@@ -896,7 +896,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys",
] ]
[[package]] [[package]]
@@ -914,15 +914,6 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -998,9 +989,9 @@ dependencies = [
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.1" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
+9 -18
View File
@@ -76,9 +76,9 @@ dependencies = [
[[package]] [[package]]
name = "jiff" name = "jiff"
version = "0.2.23" version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102"
dependencies = [ dependencies = [
"jiff-static", "jiff-static",
"jiff-tzdb-platform", "jiff-tzdb-platform",
@@ -86,14 +86,14 @@ dependencies = [
"portable-atomic", "portable-atomic",
"portable-atomic-util", "portable-atomic-util",
"serde_core", "serde_core",
"windows-sys", "windows-link",
] ]
[[package]] [[package]]
name = "jiff-static" name = "jiff-static"
version = "0.2.23" version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -133,9 +133,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]] [[package]]
name = "libfuzzer-sys" name = "libfuzzer-sys"
version = "0.4.12" version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [ dependencies = [
"arbitrary", "arbitrary",
"cc", "cc",
@@ -268,20 +268,11 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.15" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
+13 -10
View File
@@ -189,8 +189,8 @@ impl DateTimeBuilder {
/// - b. Apply time. If time carries an explicit numeric offset, apply the /// - b. Apply time. If time carries an explicit numeric offset, apply the
/// offset before setting time. /// offset before setting time.
/// - c. Apply weekday (e.g., "next Friday" or "last Monday"). /// - c. Apply weekday (e.g., "next Friday" or "last Monday").
/// - d. Apply relative adjustments (e.g., "+3 days", "-2 months"). /// - d. Apply fixed offset if present (anchors the instant).
/// - e. Apply final fixed offset if present. /// - e. Apply relative adjustments (e.g., "+3 days", "-2 months").
pub(super) fn build(self) -> Result<ParsedDateTime, error::Error> { pub(super) fn build(self) -> Result<ParsedDateTime, error::Error> {
if let Some(date) = self.date.as_ref() { if let Some(date) = self.date.as_ref() {
if date.year.unwrap_or(0) > 9999 { if date.year.unwrap_or(0) > 9999 {
@@ -317,7 +317,17 @@ impl DateTimeBuilder {
dt = dt.checked_add(Span::new().try_days(delta)?)?; dt = dt.checked_add(Span::new().try_days(delta)?)?;
} }
// 4d. Apply relative adjustments. // 4d. Apply fixed offset. This anchors the instant before relative
// adjustments so that "1970/01/01 UTC N seconds" adds N seconds to the
// fixed-offset instant rather than to a local wall-clock that may drift
// across a DST boundary (see issue uutils/coreutils#12555).
if let Some(offset) = self.offset {
let (offset, hour_adjustment) = offset.normalize();
dt = dt.checked_add(Span::new().hours(hour_adjustment))?;
dt = dt.datetime().to_zoned((&offset).try_into()?)?;
}
// 4e. Apply relative adjustments.
for rel in self.relative { for rel in self.relative {
dt = match rel { dt = match rel {
relative::Relative::Years(_) | relative::Relative::Months(_) => { relative::Relative::Years(_) | relative::Relative::Months(_) => {
@@ -338,13 +348,6 @@ impl DateTimeBuilder {
}; };
} }
// 4e. Apply final fixed offset.
if let Some(offset) = self.offset {
let (offset, hour_adjustment) = offset.normalize();
dt = dt.checked_add(Span::new().hours(hour_adjustment))?;
dt = dt.datetime().to_zoned((&offset).try_into()?)?;
}
Ok(dt) Ok(dt)
} }
+28
View File
@@ -285,3 +285,31 @@ fn test_multiple_month_skip(#[case] base: &str, #[case] input: &str, #[case] exp
fn test_embedded_timezone(#[case] input: &str, #[case] expected: &str) { fn test_embedded_timezone(#[case] input: &str, #[case] expected: &str) {
check_absolute(input, expected); check_absolute(input, expected);
} }
// Regression test for uutils/coreutils#12555.
// A fixed offset (e.g. the "UTC" keyword) must anchor the instant *before*
// relative adjustments are applied. Otherwise, when the base zone observes DST,
// adding a large relative offset drifts the wall-clock across a DST boundary
// and re-anchoring at the fixed offset bakes in the extra hour.
// "1970-01-01 UTC + N seconds" must equal exactly N seconds after the epoch,
// regardless of the base zone's DST rules.
#[test]
fn test_utc_keyword_plus_relative_seconds_across_dst() {
// Base in a DST-observing zone (Europe/Berlin); the value only matters as
// the default zone for interpreting the date, not the result.
let base = "2020-06-15 12:00:00"
.parse::<DateTime>()
.unwrap()
.to_zoned(TimeZone::get("Europe/Berlin").unwrap())
.unwrap();
// A timestamp landing in summer (CEST, UTC+2) while the epoch base is in
// winter (CET, UTC+1), so the DST offset differs between base and result.
let seconds = 1_780_318_971_i64;
let input = format!("1970-01-01 UTC {seconds} seconds");
let parsed = parse_datetime::parse_datetime_at_date(base, input)
.unwrap()
.expect_in_range();
assert_eq!(parsed.timestamp().as_second(), seconds);
}