Skip to content

Commit 651f9fa

Browse files
authored
Merge pull request GitoxideLabs#2110 from jpgrayson/fix/gix-date-parse-raw
fix(gix-date): add parse_raw()
2 parents f6e0ca4 + 4289ae6 commit 651f9fa

File tree

19 files changed

+565
-402
lines changed

19 files changed

+565
-402
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gix-actor/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ doctest = false
1919
serde = ["dep:serde", "bstr/serde", "gix-date/serde"]
2020

2121
[dependencies]
22-
gix-date = { version = "^0.10.4", path = "../gix-date" }
22+
gix-date = { version = "^0.10.5", path = "../gix-date" }
2323
gix-utils = { version = "^0.3.0", path = "../gix-utils" }
2424

2525
thiserror = "2.0.0"

gix-archive/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ zip = ["dep:zip"]
3030
gix-worktree-stream = { version = "^0.22.0", path = "../gix-worktree-stream" }
3131
gix-object = { version = "^0.50.1", path = "../gix-object" }
3232
gix-path = { version = "^0.10.20", path = "../gix-path", optional = true }
33-
gix-date = { version = "^0.10.4", path = "../gix-date" }
33+
gix-date = { version = "^0.10.5", path = "../gix-date" }
3434

3535
flate2 = { version = "1.1.1", optional = true, default-features = false, features = ["zlib-rs"] }
3636
zip = { version = "4.3.0", optional = true, default-features = false, features = ["deflate-flate2"] }

gix-blame/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ rust-version = "1.70"
1414
gix-commitgraph = { version = "^0.29.0", path = "../gix-commitgraph" }
1515
gix-revwalk = { version = "^0.21.0", path = "../gix-revwalk" }
1616
gix-trace = { version = "^0.1.13", path = "../gix-trace" }
17-
gix-date = { version = "^0.10.4", path = "../gix-date" }
17+
gix-date = { version = "^0.10.5", path = "../gix-date" }
1818
gix-diff = { version = "^0.53.0", path = "../gix-diff", default-features = false, features = ["blob"] }
1919
gix-object = { version = "^0.50.1", path = "../gix-object" }
2020
gix-hash = { version = "^0.19.0", path = "../gix-hash" }

gix-credentials/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ gix-path = { version = "^0.10.20", path = "../gix-path" }
2525
gix-command = { version = "^0.6.2", path = "../gix-command" }
2626
gix-config-value = { version = "^0.15.1", path = "../gix-config-value" }
2727
gix-prompt = { version = "^0.11.1", path = "../gix-prompt" }
28-
gix-date = { version = "^0.10.4", path = "../gix-date" }
28+
gix-date = { version = "^0.10.5", path = "../gix-date" }
2929
gix-trace = { version = "^0.1.13", path = "../gix-trace" }
3030

3131
thiserror = "2.0.0"

gix-date/CHANGELOG.md

Lines changed: 424 additions & 381 deletions
Large diffs are not rendered by default.

gix-date/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ lints.workspace = true
22

33
[package]
44
name = "gix-date"
5-
version = "0.10.4"
5+
version = "0.10.5"
66
repository = "https://github.com/GitoxideLabs/gitoxide"
77
license = "MIT OR Apache-2.0"
88
description = "A crate of the gitoxide project parsing dates the way git does"

gix-date/src/parse.rs

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ pub(crate) mod function {
165165
Time::new(val, 0)
166166
} else if let Some(val) = relative::parse(input, now).transpose()? {
167167
Time::new(val.timestamp().as_second(), val.offset().seconds())
168-
} else if let Some(val) = parse_header(input) {
168+
} else if let Some(val) = parse_raw(input) {
169169
// Format::Raw
170170
val
171171
} else {
@@ -236,6 +236,52 @@ pub(crate) mod function {
236236
Some(time)
237237
}
238238

239+
/// Strictly parse the raw commit header format like `1745582210 +0200`.
240+
///
241+
/// Some strict rules include:
242+
///
243+
/// - The timezone offset must be present.
244+
/// - The timezone offset must have a sign; either `+` or `-`.
245+
/// - The timezone offset hours must be less than or equal to 14.
246+
/// - The timezone offset minutes must be exactly 0, 15, 30, or 45.
247+
/// - The timezone offset seconds may be present, but 0 is the only valid value.
248+
/// - Only whitespace may suffix the timezone offset.
249+
///
250+
/// But this function isn't perfectly strict insofar as it allows arbitrary
251+
/// whitespace before and after the seconds and offset components.
252+
///
253+
/// The goal is to only accept inputs that _unambiguously_ look like
254+
/// git's raw date format.
255+
fn parse_raw(input: &str) -> Option<Time> {
256+
let mut split = input.split_whitespace();
257+
let seconds = split.next()?.parse::<SecondsSinceUnixEpoch>().ok()?;
258+
let offset_str = split.next()?;
259+
if split.next().is_some() {
260+
return None;
261+
}
262+
let offset_len = offset_str.len();
263+
if offset_len != 5 && offset_len != 7 {
264+
return None;
265+
}
266+
let sign: i32 = match offset_str.get(..1)? {
267+
"-" => Some(-1),
268+
"+" => Some(1),
269+
_ => None,
270+
}?;
271+
let hours: u8 = offset_str.get(1..3)?.parse().ok()?;
272+
let minutes: u8 = offset_str.get(3..5)?.parse().ok()?;
273+
let offset_seconds: u8 = if offset_len == 7 {
274+
offset_str.get(5..7)?.parse().ok()?
275+
} else {
276+
0
277+
};
278+
if hours > 14 || (minutes != 0 && minutes != 15 && minutes != 30 && minutes != 45) || offset_seconds != 0 {
279+
return None;
280+
}
281+
let offset: i32 = sign * ((hours as i32) * 3600 + (minutes as i32) * 60);
282+
Time { seconds, offset }.into()
283+
}
284+
239285
/// This is just like `Zoned::strptime`, but it allows parsing datetimes
240286
/// whose weekdays are inconsistent with the date. While the day-of-week
241287
/// still must be parsed, it is otherwise ignored. This seems to be
@@ -252,6 +298,75 @@ pub(crate) mod function {
252298
static P: rfc2822::DateTimeParser = rfc2822::DateTimeParser::new().relaxed_weekday(true);
253299
P.parse_zoned(input)
254300
}
301+
302+
#[cfg(test)]
303+
mod tests {
304+
use super::*;
305+
306+
#[test]
307+
fn parse_raw_valid() {
308+
// These examples show how it's more loose than it has to be,
309+
// merely as a side effect of the implementation.
310+
for (valid, expected_seconds, expected_offset) in [
311+
("12345 +0000", 12345, 0),
312+
("-1234567 +0000", -1234567, 0),
313+
("+1234567 -000000", 1234567, 0),
314+
(" +0 -000000 ", 0, 0),
315+
("\t-0\t-0000\t", 0, 0),
316+
("\n-0\r\n-0000\n", 0, 0),
317+
] {
318+
assert_eq!(
319+
parse_raw(valid),
320+
Some(Time {
321+
seconds: expected_seconds,
322+
offset: expected_offset
323+
}),
324+
"should succeed: '{valid}'"
325+
);
326+
}
327+
}
328+
329+
#[test]
330+
fn parse_raw_invalid() {
331+
for (bad_date_str, message) in [
332+
("123456 !0600", "invalid sign - must be + or -"),
333+
("123456 0600", "missing offset sign"),
334+
("123456 +060", "positive offset too short"),
335+
("123456 -060", "negative offset too short"),
336+
("123456 +06000", "not enough offset seconds"),
337+
("123456 --060", "duplicate offset sign with correct offset length"),
338+
("123456 -+060", "multiple offset signs with correct offset length"),
339+
("123456 --0600", "multiple offset signs, but incorrect offset length"),
340+
("123456 +-06000", "multiple offset signs with correct offset length"),
341+
("123456 +-0600", "multiple offset signs with incorrect offset length"),
342+
("123456 +-060", "multiple offset signs with correct offset length"),
343+
("123456 +10030", "invalid offset length with one 'second' field"),
344+
("123456 06000", "invalid offset length, missing sign"),
345+
("123456 +0600 extra", "extra field past offset"),
346+
("123456 +0600 2005", "extra field past offset that looks like year"),
347+
("123456+0600", "missing space between unix timestamp and offset"),
348+
(
349+
"123456 + 600",
350+
"extra spaces between sign and offset (which also is too short)",
351+
),
352+
("123456 -1500", "negative offset hours out of bounds"),
353+
("123456 +1500", "positive offset hours out of bounds"),
354+
("123456 +6600", "positive offset hours out of bounds"),
355+
("123456 +0660", "invalid offset minutes"),
356+
("123456 +060010", "positive offset seconds is allowed but only if zero"),
357+
("123456 -060010", "negative offset seconds is allowed but only if zero"),
358+
("123456 +0075", "positive offset minutes invalid"),
359+
("++123456 +0000", "duplicate timestamp sign"),
360+
("--123456 +0000", "duplicate timestamp sign"),
361+
("1234567 -+1+1+0", "unsigned offset parsing rejects '+'"),
362+
] {
363+
assert!(
364+
parse_raw(bad_date_str).is_none(),
365+
"should fail: '{bad_date_str}': {message}"
366+
);
367+
}
368+
}
369+
}
255370
}
256371

257372
mod relative {

gix-date/tests/time/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ mod write_to {
101101
assert_eq!(output.as_bstr(), expected);
102102
assert_eq!(time.size(), output.len());
103103

104-
let actual = gix_date::parse(&output.as_bstr().to_string(), None).expect("round-trippable");
104+
let actual = output.as_bstr().to_string().parse::<Time>().expect("round-trippable");
105105
assert_eq!(time, actual);
106106
}
107107
Ok(())

gix-date/tests/time/parse.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,27 @@ fn raw() -> gix_testtools::Result {
8383
);
8484

8585
assert_eq!(
86-
gix_date::parse("1313584730 +051800", None)?,
86+
gix_date::parse("1313584730 +051500", None)?,
8787
Time {
8888
seconds: 1313584730,
89-
offset: 19080,
89+
offset: 18900,
9090
},
9191
"seconds for time-offsets work as well"
9292
);
9393

9494
assert_eq!(
95-
gix_date::parse("1313584730 +051842", None)?,
95+
gix_date::parse("1313584730 -0230", None)?,
9696
Time {
9797
seconds: 1313584730,
98-
offset: 19122,
98+
offset: -150 * 60,
9999
},
100100
);
101101

102+
assert!(gix_date::parse("1313584730 +1500", None).is_err());
103+
assert!(gix_date::parse("1313584730 +000001", None).is_err());
104+
assert!(gix_date::parse("1313584730 +0001", None).is_err());
105+
assert!(gix_date::parse("1313584730 +000100", None).is_err());
106+
102107
let expected = Time {
103108
seconds: 1660874655,
104109
offset: -28800,

0 commit comments

Comments
 (0)