1- use alloc:: { string:: String , vec:: Vec } ;
1+ use alloc:: { borrow :: Cow , string:: String , vec:: Vec } ;
22use core:: fmt;
33
44use 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.
143259struct 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.
155271struct 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) ]
390506fn 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) ]
453569fn 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,
0 commit comments