Skip to content

Commit 7a7cf3f

Browse files
feat(zip)!: reject over-long file names with validated ZipPath type
The name length field in local and central directory headers is 16 bits. encode_local_header truncated the length with `as u16` while still writing all of the name bytes, so an oversized name silently produced a corrupt archive (unzip: "bad zipfile offset", Python: BadZipFile) instead of failing at write time. The limit is data-dependent - paths often come from user input - so it must surface as a recoverable error rather than a panic, which would fire inside ZipWriter::poll_next and take down an async task mid-response. Add ZipPath, a Cow<'static, str> wrapper that validates the length once on construction and is required by ZipArchive and ZipEntry, making oversized names unrepresentable and surfacing the error before any archive bytes are written. Paths built from string literals are stored borrowed without allocating, and the rejected path can be recovered from InvalidZipPath. Lifecycle violations ("previous file not ended") still panic as before.
1 parent 78e2a56 commit 7a7cf3f

8 files changed

Lines changed: 178 additions & 34 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ async fn main() -> io::Result<()> {
2424

2525
let entries = stream::iter([
2626
Ok(ZipEntry::File {
27-
path: "hello.txt".into(),
27+
path: "hello.txt".try_into().unwrap(),
2828
modified,
2929
content: stream::iter([Ok::<_, io::Error>(Bytes::from_static(b"Hello, world!"))]),
3030
}),
3131
Ok(ZipEntry::Directory {
32-
path: "subdir/".into(),
32+
path: "subdir/".try_into().unwrap(),
3333
modified,
3434
}),
3535
]);

examples/deflate_zip.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use std::{
1313
};
1414

1515
use bytes::BytesMut;
16-
use cerniera::{CompressionMethod, MsDosDateTime, ZipArchive};
16+
use cerniera::{CompressionMethod, MsDosDateTime, ZipArchive, ZipPath};
1717
use flate2::{Compression, write::DeflateEncoder};
1818
use jiff::Timestamp;
1919

@@ -39,6 +39,7 @@ fn main() -> io::Result<()> {
3939
.try_into()
4040
.expect("modification time out of MS-DOS range");
4141

42+
let path = ZipPath::new(path).expect("file name too long");
4243
archive.start_file(path, modified, CompressionMethod::Deflate, &mut buf);
4344
out.write_all(&buf)?;
4445
buf.clear();

examples/sendfile_zip.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use std::{env, fs::File, io, io::Write, process};
88

99
use bytes::BytesMut;
10-
use cerniera::{CompressionMethod, MsDosDateTime, ZipArchive};
10+
use cerniera::{CompressionMethod, MsDosDateTime, ZipArchive, ZipPath};
1111
use jiff::{Timestamp, tz::TimeZone};
1212
use memmap2::Mmap;
1313

@@ -34,6 +34,7 @@ fn main() -> io::Result<()> {
3434
.try_into()
3535
.expect("modification time out of MS-DOS range");
3636

37+
let path = ZipPath::new(path).expect("file name too long");
3738
archive.start_file(path, modified, CompressionMethod::Stored, &mut buf);
3839
out.write_all(&buf)?;
3940
buf.clear();

examples/write_zip.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@ async fn main() -> io::Result<()> {
1717

1818
let entries: Vec<io::Result<_>> = vec![
1919
Ok(ZipEntry::File {
20-
path: "hello.txt".into(),
20+
path: "hello.txt".try_into().unwrap(),
2121
modified,
2222
content: stream::iter([Ok(Bytes::from_static(b"Hello, world!"))]),
2323
}),
2424
Ok(ZipEntry::Directory {
25-
path: "subdir/".into(),
25+
path: "subdir/".try_into().unwrap(),
2626
modified,
2727
}),
2828
Ok(ZipEntry::File {
29-
path: "subdir/notes.txt".into(),
29+
path: "subdir/notes.txt".try_into().unwrap(),
3030
modified,
3131
content: stream::iter([Ok(Bytes::from_static(b"Some notes in a subdir.\n"))]),
3232
}),

src/archive.rs

Lines changed: 146 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use alloc::{string::String, vec::Vec};
1+
use alloc::{borrow::Cow, string::String, vec::Vec};
22
use core::fmt;
33

44
use bytes::{BufMut, BytesMut};
@@ -117,6 +117,122 @@ impl TryFrom<jiff::civil::DateTime> for MsDosDateTime {
117117
}
118118
}
119119

120+
// ── Paths ─────────────────────────────────────────────────────────────────────
121+
122+
/// Error returned when a path does not fit ZIP's 16-bit name length field.
123+
///
124+
/// Returned by [`ZipPath::new`]. The rejected path can be recovered with
125+
/// [`into_inner`](Self::into_inner).
126+
#[derive(Debug)]
127+
pub struct InvalidZipPath {
128+
path: Cow<'static, str>,
129+
}
130+
131+
impl InvalidZipPath {
132+
/// Return the rejected path.
133+
#[must_use]
134+
pub fn into_inner(self) -> Cow<'static, str> {
135+
self.path
136+
}
137+
}
138+
139+
impl fmt::Display for InvalidZipPath {
140+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141+
write!(
142+
f,
143+
"file name length {} exceeds 65535 bytes",
144+
self.path.len()
145+
)
146+
}
147+
}
148+
149+
impl core::error::Error for InvalidZipPath {}
150+
151+
/// A ZIP entry path, validated to fit the format's 16-bit name length field.
152+
///
153+
/// Holds a [`Cow`], so paths built from `&'static str` (e.g. string
154+
/// literals) don't allocate:
155+
///
156+
/// ```rust
157+
/// use cerniera::ZipPath;
158+
///
159+
/// // Borrowed - no allocation.
160+
/// let path = ZipPath::new("docs/report.pdf").unwrap();
161+
/// // Owned.
162+
/// let path: ZipPath = String::from("docs/report.pdf").try_into().unwrap();
163+
/// ```
164+
///
165+
/// Paths are stored as-is: cerniera does not normalize separators or reject
166+
/// special components such as `..`; callers zipping untrusted names should
167+
/// sanitize them first.
168+
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
169+
pub struct ZipPath(Cow<'static, str>);
170+
171+
impl ZipPath {
172+
/// Validate that `path` fits the 16-bit name length field.
173+
///
174+
/// # Errors
175+
///
176+
/// Returns [`InvalidZipPath`] if `path` is longer than 65535 bytes;
177+
/// the rejected path can be recovered from the error.
178+
pub fn new(path: impl Into<Cow<'static, str>>) -> Result<Self, InvalidZipPath> {
179+
let path = path.into();
180+
if u16::try_from(path.len()).is_ok() {
181+
Ok(Self(path))
182+
} else {
183+
Err(InvalidZipPath { path })
184+
}
185+
}
186+
187+
/// View the path as a string slice.
188+
#[must_use]
189+
pub fn as_str(&self) -> &str {
190+
&self.0
191+
}
192+
193+
/// Extract the inner [`Cow`].
194+
#[must_use]
195+
pub fn into_inner(self) -> Cow<'static, str> {
196+
self.0
197+
}
198+
}
199+
200+
impl AsRef<str> for ZipPath {
201+
fn as_ref(&self) -> &str {
202+
&self.0
203+
}
204+
}
205+
206+
impl fmt::Display for ZipPath {
207+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208+
f.write_str(&self.0)
209+
}
210+
}
211+
212+
impl TryFrom<&'static str> for ZipPath {
213+
type Error = InvalidZipPath;
214+
215+
fn try_from(path: &'static str) -> Result<Self, Self::Error> {
216+
Self::new(path)
217+
}
218+
}
219+
220+
impl TryFrom<String> for ZipPath {
221+
type Error = InvalidZipPath;
222+
223+
fn try_from(path: String) -> Result<Self, Self::Error> {
224+
Self::new(path)
225+
}
226+
}
227+
228+
impl TryFrom<Cow<'static, str>> for ZipPath {
229+
type Error = InvalidZipPath;
230+
231+
fn try_from(path: Cow<'static, str>) -> Result<Self, Self::Error> {
232+
Self::new(path)
233+
}
234+
}
235+
120236
// ── Compression ──────────────────────────────────────────────────────────────
121237

122238
/// Compression method stored in ZIP file headers.
@@ -141,7 +257,7 @@ pub enum CompressionMethod {
141257

142258
/// Central-directory metadata accumulated as each entry is completed.
143259
struct CdEntry {
144-
path: String,
260+
path: ZipPath,
145261
modified: MsDosDateTime,
146262
method: CompressionMethod,
147263
crc32: u32,
@@ -153,7 +269,7 @@ struct CdEntry {
153269

154270
/// Tracks the in-flight file entry whose content is being fed.
155271
struct ActiveFile {
156-
path: String,
272+
path: ZipPath,
157273
modified: MsDosDateTime,
158274
method: CompressionMethod,
159275
uncompressed_size: u64,
@@ -216,7 +332,7 @@ impl ZipArchive {
216332
/// or [`end_file_compressed`](Self::end_file_compressed).
217333
pub fn start_file(
218334
&mut self,
219-
path: String,
335+
path: ZipPath,
220336
modified: MsDosDateTime,
221337
method: CompressionMethod,
222338
buf: &mut BytesMut,
@@ -225,7 +341,7 @@ impl ZipArchive {
225341

226342
let local_offset = self.offset;
227343
let before = buf.len();
228-
encode_local_header(&path, modified, method, buf);
344+
encode_local_header(path.as_str(), modified, method, buf);
229345
self.offset += (buf.len() - before) as u64;
230346

231347
self.active = Some(ActiveFile {
@@ -324,12 +440,12 @@ impl ZipArchive {
324440
///
325441
/// Panics if a previous file was not ended with [`end_file`](Self::end_file)
326442
/// or [`end_file_compressed`](Self::end_file_compressed).
327-
pub fn add_directory(&mut self, path: String, modified: MsDosDateTime, buf: &mut BytesMut) {
443+
pub fn add_directory(&mut self, path: ZipPath, modified: MsDosDateTime, buf: &mut BytesMut) {
328444
assert!(self.active.is_none(), "previous file not ended");
329445

330446
let local_offset = self.offset;
331447
let before = buf.len();
332-
encode_local_header(&path, modified, CompressionMethod::Stored, buf);
448+
encode_local_header(path.as_str(), modified, CompressionMethod::Stored, buf);
333449
encode_data_descriptor(0, 0, 0, buf);
334450
self.offset += (buf.len() - before) as u64;
335451

@@ -385,7 +501,7 @@ impl Default for ZipArchive {
385501
/// data, and in the central directory's ZIP64 extra field.
386502
#[expect(
387503
clippy::cast_possible_truncation,
388-
reason = "file names > 64 KiB are unsupported"
504+
reason = "ZipPath guarantees the name fits in u16"
389505
)]
390506
fn encode_local_header(
391507
path: &str,
@@ -448,11 +564,11 @@ const CD_EXTRA_LEN: u16 = 28;
448564

449565
#[expect(
450566
clippy::cast_possible_truncation,
451-
reason = "file names > 64 KiB are unsupported"
567+
reason = "ZipPath guarantees the name fits in u16"
452568
)]
453569
fn encode_cd_entry(e: &CdEntry, b: &mut BytesMut) {
454-
let name = e.path.as_bytes();
455-
let external_attr: u32 = if e.path.ends_with('/') {
570+
let name = e.path.as_str().as_bytes();
571+
let external_attr: u32 = if e.path.as_str().ends_with('/') {
456572
0o40_755 << 16 // S_IFDIR + rwxr-xr-x
457573
} else {
458574
0o100_644 << 16 // S_IFREG + rw-r--r--
@@ -609,7 +725,7 @@ mod tests {
609725
let mut buf = BytesMut::new();
610726

611727
archive.start_file(
612-
name.into(),
728+
name.try_into().unwrap(),
613729
MsDosDateTime::default(),
614730
CompressionMethod::Stored,
615731
&mut buf,
@@ -703,7 +819,7 @@ mod tests {
703819

704820
let zip = collect_archive(|archive, out| {
705821
let mut buf = BytesMut::new();
706-
archive.add_directory(path.into(), MsDosDateTime::default(), &mut buf);
822+
archive.add_directory(path.try_into().unwrap(), MsDosDateTime::default(), &mut buf);
707823
emit(&mut buf, out);
708824
archive.finish(&mut buf);
709825
emit(&mut buf, out);
@@ -743,6 +859,21 @@ mod tests {
743859
assert_eq!(u64le(&b, 16), 42);
744860
}
745861

862+
#[test]
863+
fn zip_path_length_validation() {
864+
// Exactly the u16 limit is accepted.
865+
assert!(ZipPath::new("a".repeat(65_535)).is_ok());
866+
867+
// One byte over is rejected and the path can be recovered.
868+
let long = "a".repeat(65_536);
869+
let err = ZipPath::new(long.clone()).unwrap_err();
870+
assert_eq!(err.into_inner(), long);
871+
872+
// Static strings are stored borrowed - no allocation.
873+
let path = ZipPath::new("hello.txt").unwrap();
874+
assert!(matches!(path.into_inner(), Cow::Borrowed("hello.txt")));
875+
}
876+
746877
#[test]
747878
fn ms_dos_date_time_validation() {
748879
// Extremes of every valid range are accepted.
@@ -777,7 +908,7 @@ mod tests {
777908
let mut buf = BytesMut::new();
778909

779910
archive.start_file(
780-
"a.txt".into(),
911+
"a.txt".try_into().unwrap(),
781912
MsDosDateTime::default(),
782913
CompressionMethod::Stored,
783914
&mut buf,
@@ -789,7 +920,7 @@ mod tests {
789920
emit(&mut buf, out);
790921

791922
archive.start_file(
792-
"b.txt".into(),
923+
"b.txt".try_into().unwrap(),
793924
MsDosDateTime::default(),
794925
CompressionMethod::Stored,
795926
&mut buf,

src/lib.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@
2626
//!
2727
//! let entries = stream::iter([
2828
//! Ok(ZipEntry::File {
29-
//! path: "hello.txt".into(),
29+
//! path: "hello.txt".try_into().unwrap(),
3030
//! modified,
3131
//! content: stream::iter([Ok::<_, io::Error>(Bytes::from_static(b"Hello, world!"))]),
3232
//! }),
3333
//! Ok(ZipEntry::Directory {
34-
//! path: "subdir/".into(),
34+
//! path: "subdir/".try_into().unwrap(),
3535
//! modified,
3636
//! }),
3737
//! ]);
@@ -69,5 +69,7 @@ extern crate alloc;
6969
pub mod archive;
7070
mod stream;
7171

72-
pub use self::archive::{CompressionMethod, InvalidMsDosDateTime, MsDosDateTime, ZipArchive};
72+
pub use self::archive::{
73+
CompressionMethod, InvalidMsDosDateTime, InvalidZipPath, MsDosDateTime, ZipArchive, ZipPath,
74+
};
7375
pub use self::stream::{ZipEntry, ZipWriter};

src/stream.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
use alloc::string::String;
21
use core::{
32
pin::Pin,
43
task::{Context, Poll},
@@ -8,14 +7,14 @@ use bytes::{Bytes, BytesMut};
87
use futures_core::Stream;
98
use pin_project_lite::pin_project;
109

11-
use crate::archive::{CompressionMethod, MsDosDateTime, ZipArchive};
10+
use crate::archive::{CompressionMethod, MsDosDateTime, ZipArchive, ZipPath};
1211

1312
/// One entry (file or directory) passed to [`ZipWriter`].
1413
pub enum ZipEntry<S> {
1514
/// A file entry.
1615
File {
1716
/// Path inside the archive, e.g. `"images/photo.jpg"`.
18-
path: String,
17+
path: ZipPath,
1918
/// Last-modified date and time.
2019
modified: MsDosDateTime,
2120
/// Raw byte stream.
@@ -24,7 +23,7 @@ pub enum ZipEntry<S> {
2423
/// A directory entry. `path` should end with `'/'`.
2524
Directory {
2625
/// Path inside the archive, e.g. `"subdir/"`.
27-
path: String,
26+
path: ZipPath,
2827
/// Last-modified date and time.
2928
modified: MsDosDateTime,
3029
},

0 commit comments

Comments
 (0)