Skip to content

Commit 9cd9520

Browse files
feat(zip)!: validate MsDosDateTime::new returns Option, add days_in_month
MS-DOS date validation rejects invalid components, including day bounds per month/year with leap year support.
1 parent e50d597 commit 9cd9520

6 files changed

Lines changed: 73 additions & 29 deletions

File tree

examples/deflate_zip.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ fn main() -> io::Result<()> {
3636
.expect("modification time out of range")
3737
.to_zoned(jiff::tz::TimeZone::system())
3838
.datetime()
39-
.into();
39+
.try_into()
40+
.expect("modification time out of MS-DOS range");
4041

4142
archive.start_file(path, modified, CompressionMethod::Deflate, &mut buf);
4243
out.write_all(&buf)?;

examples/sendfile_zip.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ fn main() -> io::Result<()> {
3131
.expect("modification time out of range")
3232
.to_zoned(TimeZone::system())
3333
.datetime()
34-
.into();
34+
.try_into()
35+
.expect("modification time out of MS-DOS range");
3536

3637
archive.start_file(path, modified, CompressionMethod::Stored, &mut buf);
3738
out.write_all(&buf)?;

examples/write_zip.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use tokio::{fs::File, io::AsyncWriteExt};
1313

1414
#[tokio::main(flavor = "current_thread")]
1515
async fn main() -> io::Result<()> {
16-
let modified = MsDosDateTime::new(2026, 3, 10, 12, 30, 0);
16+
let modified = MsDosDateTime::new(2026, 3, 10, 12, 30, 0).unwrap();
1717

1818
let entries: Vec<io::Result<_>> = vec![
1919
Ok(ZipEntry::File {

src/archive.rs

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use alloc::{string::String, vec::Vec};
2+
use core::fmt;
23

34
use bytes::{BufMut, BytesMut};
45
use crc32fast::Hasher as Crc32Hasher;
@@ -22,6 +23,19 @@ const VERSION_MADE_BY: u16 = (3 << 8) | 0x2D;
2223

2324
// ── Date / time ──────────────────────────────────────────────────────────────
2425

26+
/// Error returned when [`MsDosDateTime::new`] is given an invalid date or time.
27+
#[derive(Debug)]
28+
#[non_exhaustive]
29+
pub struct InvalidMsDosDateTime;
30+
31+
impl fmt::Display for InvalidMsDosDateTime {
32+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33+
write!(f, "invalid MS-DOS date/time")
34+
}
35+
}
36+
37+
impl core::error::Error for InvalidMsDosDateTime {}
38+
2539
/// MS-DOS date and time as stored in ZIP file headers.
2640
///
2741
/// The default value is all zeros, which most extractors display as
@@ -37,39 +51,67 @@ pub struct MsDosDateTime {
3751
impl MsDosDateTime {
3852
/// Create from individual calendar components.
3953
///
40-
/// Values are packed as-is with no validation. Out-of-range inputs
41-
/// will produce a garbage timestamp but will not panic.
54+
/// Returns `None` if any field is out of the valid MS-DOS range.
55+
/// The day is validated against the actual number of days in the given
56+
/// month and year (including leap years for February).
4257
///
4358
/// * `year` - 1980..=2107
4459
/// * `month` - 1..=12
45-
/// * `day` - 1..=31
60+
/// * `day` - 1..=days-in-month
4661
/// * `hour` - 0..=23
4762
/// * `minute` - 0..=59
48-
/// * `second` - 0..=58 (rounded down to even)
63+
/// * `second` - 0..=59 (truncated to even)
4964
#[must_use]
50-
pub const fn new(year: u16, month: u16, day: u16, hour: u16, minute: u16, second: u16) -> Self {
51-
Self {
65+
pub const fn new(
66+
year: u16,
67+
month: u16,
68+
day: u16,
69+
hour: u16,
70+
minute: u16,
71+
second: u16,
72+
) -> Option<Self> {
73+
if year < 1980
74+
|| year > 2107
75+
|| month < 1
76+
|| month > 12
77+
|| day < 1
78+
|| day > days_in_month(year, month)
79+
|| second > 59
80+
{
81+
return None;
82+
}
83+
Some(Self {
5284
time: (hour << 11) | (minute << 5) | (second / 2),
5385
date: ((year - 1980) << 9) | (month << 5) | day,
54-
}
86+
})
87+
}
88+
}
89+
90+
/// Return the number of days in the given month and year.
91+
///
92+
/// Returns `0` for months outside 1..=12.
93+
const fn days_in_month(year: u16, month: u16) -> u16 {
94+
match month {
95+
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
96+
4 | 6 | 9 | 11 => 30,
97+
2 if year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400) => 29,
98+
2 => 28,
99+
_ => 0,
55100
}
56101
}
57102

58103
#[cfg(feature = "jiff")]
59-
impl From<jiff::civil::DateTime> for MsDosDateTime {
60-
#[expect(
61-
clippy::cast_sign_loss,
62-
reason = "jiff date/time components are non-negative"
63-
)]
64-
fn from(dt: jiff::civil::DateTime) -> Self {
65-
Self::new(
66-
dt.year() as u16,
67-
dt.month() as u16,
68-
dt.day() as u16,
69-
dt.hour() as u16,
70-
dt.minute() as u16,
71-
dt.second() as u16,
72-
)
104+
impl TryFrom<jiff::civil::DateTime> for MsDosDateTime {
105+
type Error = InvalidMsDosDateTime;
106+
107+
fn try_from(dt: jiff::civil::DateTime) -> Result<Self, Self::Error> {
108+
let year: u16 = dt.year().try_into().map_err(|_| InvalidMsDosDateTime)?;
109+
let month: u16 = dt.month().try_into().map_err(|_| InvalidMsDosDateTime)?;
110+
let day: u16 = dt.day().try_into().map_err(|_| InvalidMsDosDateTime)?;
111+
let hour: u16 = dt.hour().try_into().map_err(|_| InvalidMsDosDateTime)?;
112+
let minute: u16 = dt.minute().try_into().map_err(|_| InvalidMsDosDateTime)?;
113+
let second: u16 = dt.second().try_into().map_err(|_| InvalidMsDosDateTime)?;
114+
Self::new(year, month, day, hour, minute, second).ok_or(InvalidMsDosDateTime)
73115
}
74116
}
75117

src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
//!
2323
//! # #[tokio::main(flavor = "current_thread")]
2424
//! # async fn main() -> io::Result<()> {
25-
//! let modified = MsDosDateTime::new(2026, 3, 10, 12, 30, 0);
25+
//! let modified = MsDosDateTime::new(2026, 3, 10, 12, 30, 0).unwrap();
2626
//!
2727
//! let entries = stream::iter([
2828
//! Ok(ZipEntry::File {
@@ -69,5 +69,5 @@ extern crate alloc;
6969
pub mod archive;
7070
mod stream;
7171

72-
pub use self::archive::{CompressionMethod, MsDosDateTime, ZipArchive};
72+
pub use self::archive::{CompressionMethod, InvalidMsDosDateTime, MsDosDateTime, ZipArchive};
7373
pub use self::stream::{ZipEntry, ZipWriter};

tests/roundtrip.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ fn empty_archive() {
3131
#[test]
3232
fn single_stored_file() {
3333
let content = b"Hello, cerniera!";
34-
let modified = MsDosDateTime::new(2026, 3, 10, 12, 30, 0);
34+
let modified = MsDosDateTime::new(2026, 3, 10, 12, 30, 0).unwrap();
3535

3636
let zip_bytes = build_archive(|archive, buf, out| {
3737
archive.start_file("hello.txt".into(), modified, CompressionMethod::Stored, buf);
@@ -58,7 +58,7 @@ fn single_stored_file() {
5858

5959
#[test]
6060
fn multiple_files_and_directory() {
61-
let modified = MsDosDateTime::new(2026, 3, 10, 14, 0, 0);
61+
let modified = MsDosDateTime::new(2026, 3, 10, 14, 0, 0).unwrap();
6262

6363
let zip_bytes = build_archive(|archive, buf, out| {
6464
// Directory
@@ -117,7 +117,7 @@ fn deflate_compressed_file() {
117117
use std::io::Write;
118118

119119
let content = b"the quick brown fox jumps over the lazy dog, again and again and again";
120-
let modified = MsDosDateTime::new(2026, 3, 10, 16, 0, 0);
120+
let modified = MsDosDateTime::new(2026, 3, 10, 16, 0, 0).unwrap();
121121

122122
let zip_bytes = build_archive(|archive, buf, out| {
123123
archive.start_file(

0 commit comments

Comments
 (0)