zip 9.0.4

Library to support the reading and writing of zip files.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
//! Path manipulation utilities

use std::{
    collections::VecDeque,
    ffi::{OsStr, OsString},
    fs,
    path::{Component, Path, PathBuf},
};
use typed_path::{Utf8WindowsComponent, Utf8WindowsPath};

use crate::{
    read::extract::make_writable_dir_all,
    result::{ZipResult, invalid},
};

/// Simplify an archive entry name into components to be resolved *under*
/// the destination directory.
///
/// NOTE: This intentionally drops absolute-path information (`Prefix` /
/// `RootDir`). The result MUST NOT be used for containment checks; use
/// [`resolve_enclosed`] instead.
pub(crate) fn simplified_components(input: &Path) -> Option<Vec<&OsStr>> {
    let mut out = Vec::new();
    let input_str = input.to_str()?;
    for component in Utf8WindowsPath::new(input_str).components() {
        match component {
            // Skip prefix and root directory components instead of rejecting the entire path
            // This allows extraction of ZIP files with absolute paths, similar to other ZIP tools
            Utf8WindowsComponent::Prefix(_) | Utf8WindowsComponent::RootDir => (),
            Utf8WindowsComponent::ParentDir => {
                out.pop()?;
            }
            Utf8WindowsComponent::Normal(s) => {
                // As in `enclosed_name`: a separator inside one component means the name is
                // split differently when it is written.
                if s.contains(['/', '\\']) {
                    return None;
                }
                out.push(OsStr::new(s));
            }
            Utf8WindowsComponent::CurDir => (),
        }
    }
    Some(out)
}

pub(crate) fn file_name_sanitized(no_null_filename: &str) -> PathBuf {
    Utf8WindowsPath::new(no_null_filename)
        .components()
        .filter_map(|component| match component {
            Utf8WindowsComponent::Normal(s) => Some(s),
            _ => None,
        })
        .collect()
}

pub(crate) fn enclosed_name(file_name: &str) -> Option<PathBuf> {
    let mut depth = 0usize;
    let mut out_path = PathBuf::new();
    for component in Utf8WindowsPath::new(file_name).components() {
        match component {
            Utf8WindowsComponent::Prefix(_) | Utf8WindowsComponent::RootDir => {
                if depth > 0 {
                    return None;
                }
            }
            Utf8WindowsComponent::ParentDir => {
                depth = depth.checked_sub(1)?;
                out_path.pop();
            }
            Utf8WindowsComponent::Normal(s) => {
                // Inside a verbatim (`\\?\`) path `/` is not a separator, so `../b` comes back
                // as one component; joined onto an ordinary path it is split again. The check
                // here and the eventual write must agree on where a component ends.
                if s.contains(['/', '\\']) {
                    return None;
                }
                depth += 1;
                out_path.push(s);
            }
            Utf8WindowsComponent::CurDir => (),
        }
    }
    Some(out_path)
}

/// Strip `base` from the front of the absolute path `path`, returning the components
/// that follow it, or `None` if `path` is not inside `base`.
///
/// This is [`Path::strip_prefix`] with one addition: on Windows it also accepts a `path`
/// that denotes the same volume as `base` in a different form. `base` is canonicalized
/// and therefore uses the verbatim form (`\\?\C:\...`), while a target taken from an
/// archive or from [`std::fs::read_link`] is usually in the ordinary form (`C:\...`).
/// Without this, every absolute target would be rejected on Windows.
pub(crate) fn strip_base_prefix<'p>(base: &Path, path: &'p Path) -> Option<&'p Path> {
    if let Ok(rest) = path.strip_prefix(base) {
        return Some(rest);
    }
    strip_equivalent_prefix(base, path)
}

/// Windows-only fallback of [`strip_base_prefix`], comparing the two prefixes by the
/// volume they denote rather than by their spelling.
///
/// The `#[cfg]` here is on the platform doing the extraction, not on the platform that
/// wrote the archive. An archive is free to contain a verbatim prefix — `\\?\C:\foo` as
/// an entry name or as a symlink target — but `base` only ever reaches this function
/// through `fs::canonicalize` (`ZipArchive::extract`, `ZipArchive::extract_unwrapped_root_dir`
/// and `ZipStreamReader::extract` all canonicalize the destination), so it is always
/// spelled natively, and only Windows parses the other side back into a `Prefix`
/// component; elsewhere it stays an ordinary file name.
///
/// What the archive itself supplies is covered on every platform instead: entry names,
/// which are parsed with `Utf8WindowsPath` whatever the host is, by
/// `test_simplified_components_verbatim_prefix` and `test_enclosed_name_verbatim_prefix`
/// below, and symlink targets by `test_symlink_target_with_verbatim_prefix_cannot_escape`
/// in `tests/extract_symlink.rs`.
#[cfg(windows)]
fn strip_equivalent_prefix<'p>(base: &Path, path: &'p Path) -> Option<&'p Path> {
    use std::path::Prefix;

    /// Reduce the verbatim forms to the form they are equivalent to, so that `\\?\C:\`
    /// and `C:\`, or `\\?\UNC\server\share` and `\\server\share`, compare equal. Drive
    /// letters are case-insensitive, but nothing else is normalized here: the remaining
    /// components are still compared exactly.
    fn volume(prefix: Prefix<'_>) -> Prefix<'_> {
        match prefix {
            Prefix::Disk(disk) | Prefix::VerbatimDisk(disk) => {
                Prefix::Disk(disk.to_ascii_uppercase())
            }
            Prefix::VerbatimUNC(server, share) => Prefix::UNC(server, share),
            other => other,
        }
    }

    let mut base_components = base.components();
    let mut path_components = path.components();

    match (base_components.next(), path_components.next()) {
        (Some(Component::Prefix(base_prefix)), Some(Component::Prefix(path_prefix)))
            if volume(base_prefix.kind()) == volume(path_prefix.kind()) => {}
        _ => return None,
    }

    loop {
        // Taken before advancing, so that it is the remainder once `base` runs out.
        let rest = path_components.as_path();
        match (base_components.next(), path_components.next()) {
            (None, _) => return Some(rest),
            (Some(base_component), Some(path_component)) if base_component == path_component => {}
            _ => return None,
        }
    }
}

#[cfg(not(windows))]
fn strip_equivalent_prefix<'p>(_base: &Path, _path: &'p Path) -> Option<&'p Path> {
    None
}

/// The maximum number of symlinks that may be followed while resolving a single
/// path (the equivalent of `MAXSYMLINKS` on Linux).
const MAX_SYMLINK_HOPS: usize = 40;

/// Resolve `components` into a path that is guaranteed to stay inside `base`.
///
/// Resolution starts at `start`, which must be `base` itself or a path inside it,
/// and walks one component at a time. When a symlink is encountered, its target is
/// pushed back onto the queue rather than being trusted as-is, so every component
/// of the target is checked in exactly the same way. This is what prevents an
/// outward symlink from being smuggled in as an intermediate component of a target
/// that only *looks* enclosed.
///
/// `base` is assumed to be canonicalized. When `create_dirs` is set, missing
/// intermediate directories are created; the last component is left alone.
///
/// Returns an error if resolution would leave `base`.
pub(crate) fn resolve_enclosed(
    base: &Path,
    start: &Path,
    components: impl IntoIterator<Item = OsString>,
    create_dirs: bool,
) -> ZipResult<PathBuf> {
    debug_assert!(start.starts_with(base));

    let mut current = start.to_path_buf();
    let mut queue: VecDeque<OsString> = components.into_iter().collect();
    let mut hops = 0usize;

    // Set once a component does not exist (and is not created). A later entry may create it as a
    // symlink, which would change the meaning of any subsequent `..` to the unknown
    // target's parent rather than the symlink's parent.
    let mut missing = false;

    while let Some(component) = queue.pop_front() {
        if component.is_empty() || component == OsStr::new(".") {
            continue;
        }
        if component == OsStr::new("..") {
            if missing {
                return Err(invalid!(
                    "Path may escape the destination directory through a future symlink - '..' after a missing component"
                ));
            }
            // Never walk above `base`: `..` is rejected once we are back at `base`.
            if current == base {
                return Err(invalid!(
                    "Path escapes the destination directory - component is '..'"
                ));
            }
            current.pop();
            continue;
        }

        current.push(&component);

        let meta = match fs::symlink_metadata(&current) {
            Ok(meta) => meta,
            // Nothing to follow here, so keep walking; this is also what allows a
            // symlink whose target does not exist (yet) to be accepted.
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                if create_dirs && !queue.is_empty() {
                    make_writable_dir_all(&current)?;
                } else {
                    missing = true;
                }
                continue;
            }
            Err(e) => return Err(e.into()),
        };

        if !meta.is_symlink() {
            continue;
        }

        hops += 1;
        if hops > MAX_SYMLINK_HOPS {
            return Err(invalid!("Extraction followed a symlink too deep"));
        }

        let target = fs::read_link(&current)?;
        current.pop(); // consume the symlink itself and resolve its target from its parent

        let rest: PathBuf = if target.is_absolute() {
            // An absolute target is rejected unless it can be reduced to a path relative
            // to the canonicalized `base`. Whole components are compared, so a different
            // Windows drive or UNC share does not match.
            let Some(rest) = strip_base_prefix(base, &target) else {
                return Err(invalid!("Symlink target escapes the destination directory"));
            };
            current = base.to_path_buf();
            rest.to_path_buf()
        } else {
            target
        };
        // Push the target's components back to the front of the queue, so that its
        // intermediate components are checked just like any other component.
        for c in rest.components().rev() {
            match c {
                Component::Prefix(_) | Component::RootDir => {
                    return Err(invalid!("Invalid symlink target path"));
                }
                Component::CurDir => (),
                Component::ParentDir => queue.push_front(OsString::from("..")),
                Component::Normal(s) => queue.push_front(s.to_os_string()),
            }
        }
    }
    // After processing all components, verify we're still inside base
    if !current.starts_with(base) {
        return Err(invalid!(
            "Path escapes the destination directory and not allowed outside"
        ));
    }
    Ok(current)
}

#[cfg(test)]
mod tests {
    use super::{enclosed_name, simplified_components};
    use std::path::{Path, PathBuf};

    #[test]
    fn test_simplified_components_relative_path() {
        let path = Path::new("foo/bar/baz.txt");
        let components = simplified_components(path).unwrap();
        assert_eq!(components.len(), 3);
        assert_eq!(components[0], "foo");
        assert_eq!(components[1], "bar");
        assert_eq!(components[2], "baz.txt");
    }

    #[test]
    fn test_simplified_components_absolute_unix_path() {
        let path = Path::new("/foo/bar/baz.txt");
        let components = simplified_components(path).unwrap();
        assert_eq!(components.len(), 3);
        assert_eq!(components[0], "foo");
        assert_eq!(components[1], "bar");
        assert_eq!(components[2], "baz.txt");
    }

    #[test]
    fn test_simplified_components_with_parent_dirs() {
        let path = Path::new("foo/../bar/baz.txt");
        let components = simplified_components(path).unwrap();
        assert_eq!(components.len(), 2);
        assert_eq!(components[0], "bar");
        assert_eq!(components[1], "baz.txt");
    }

    #[test]
    fn test_simplified_components_too_many_parent_dirs() {
        let path = Path::new("foo/../../bar");
        let result = simplified_components(path);
        assert!(result.is_none()); // Should still fail for directory traversal attacks
    }

    #[test]
    fn test_simplified_components_with_current_dir() {
        let path = Path::new("foo/./bar/baz.txt");
        let components = simplified_components(path).unwrap();
        assert_eq!(components.len(), 3);
        assert_eq!(components[0], "foo");
        assert_eq!(components[1], "bar");
        assert_eq!(components[2], "baz.txt");
    }

    #[test]
    fn test_simplified_components_empty_path() {
        let path = Path::new("");
        let components = simplified_components(path).unwrap();
        assert_eq!(components.len(), 0);
    }

    #[test]
    fn test_simplified_components_root_only() {
        let path = Path::new("/");
        let components = simplified_components(path).unwrap();
        assert_eq!(components.len(), 0);
    }

    #[test]
    fn test_simplified_components_windows_absolute_path() {
        let path = Path::new(r"C:\foo\bar\baz.txt");
        let components = simplified_components(path).unwrap();
        assert_eq!(components.len(), 3);
        assert_eq!(components[0], "foo");
        assert_eq!(components[1], "bar");
        assert_eq!(components[2], "baz.txt");
    }

    /// Nothing stops an archive from spelling an entry name with a verbatim prefix, and
    /// entry names are parsed with `Utf8WindowsPath` whatever the host is, so this has to
    /// hold on every platform rather than on Windows alone.
    #[test]
    fn test_simplified_components_verbatim_prefix() {
        for path in [
            r"\\?\C:\foo\bar.txt",
            r"\\?\UNC\server\share\foo\bar.txt",
            r"\\.\C:\foo\bar.txt",
        ] {
            let components = simplified_components(Path::new(path)).unwrap();
            assert_eq!(components, ["foo", "bar.txt"], "{path}");
        }

        let components = simplified_components(Path::new(r"\\?\C:\foo\..\bar.txt")).unwrap();
        assert_eq!(components, ["bar.txt"]);

        let components = simplified_components(Path::new(r"\\?\C:\")).unwrap();
        assert!(components.is_empty());

        // A verbatim prefix must not buy the entry any extra `..`s either.
        assert_eq!(
            simplified_components(Path::new(r"\\?\C:\..\..\etc\passwd")),
            None
        );
    }

    /// The same for the public [`enclosed_name`] path.
    #[test]
    fn test_enclosed_name_verbatim_prefix() {
        for name in [
            r"\\?\C:\foo\bar.txt",
            r"\\?\UNC\server\share\foo\bar.txt",
            r"\\.\C:\foo\bar.txt",
        ] {
            assert_eq!(
                enclosed_name(name),
                Some(PathBuf::from_iter(["foo", "bar.txt"])),
                "{name}"
            );
        }

        assert_eq!(enclosed_name(r"\\?\C:\..\..\etc\passwd"), None);
    }

    /// Inside a verbatim path `/` is not a separator, so the parser hands `../b` back as one
    /// `Normal` component. Once the result is joined onto an ordinary destination path, `/` is
    /// a separator again, so a caller would write one level above the destination. A name
    /// whose component holds a separator is not enclosed, as `\\?\C:\..\b` and `../b` are not.
    #[test]
    fn test_enclosed_name_verbatim_prefix_with_a_separator_inside_a_component() {
        for name in [
            r"\\?\C:\../b",
            r"\\?\C:\a/../../b",
            r"\\?\UNC\server\share\../b",
            r"\\?\C:\a\..\../b",
            r"\\?\C:\..\b",
            "../b",
        ] {
            assert_eq!(enclosed_name(name), None, "{name}");
            assert_eq!(simplified_components(Path::new(name)), None, "{name}");
        }
    }

    /// `base` is canonicalized, so on Windows it is spelled in the verbatim form. A
    /// target coming from an archive or from `read_link` usually is not, and must still
    /// be recognized as being inside `base`.
    ///
    /// Gated on the extracting platform rather than on the platform the archive came
    /// from; see the comment on `strip_equivalent_prefix` for why that distinction holds.
    #[cfg(windows)]
    #[test]
    fn test_strip_base_prefix_accepts_equivalent_windows_prefixes() {
        use super::strip_base_prefix;

        let base = Path::new(r"\\?\C:\dest");
        for path in [r"C:\dest\sub\file.txt", r"c:\dest\sub\file.txt"] {
            assert_eq!(
                strip_base_prefix(base, Path::new(path)),
                Some(Path::new(r"sub\file.txt")),
                "{path} should be inside {}",
                base.display()
            );
        }
        assert_eq!(
            strip_base_prefix(base, Path::new(r"C:\dest")),
            Some(Path::new(""))
        );

        let unc_base = Path::new(r"\\?\UNC\server\share\dest");
        assert_eq!(
            strip_base_prefix(unc_base, Path::new(r"\\server\share\dest\file.txt")),
            Some(Path::new("file.txt"))
        );
    }

    /// A different volume must not be accepted just because the rest of the path matches.
    #[test]
    fn test_strip_base_prefix_rejects_other_windows_volumes() {
        use super::strip_base_prefix;

        let base = Path::new(r"\\?\C:\dest");
        for path in [
            r"D:\dest\file.txt",
            r"\\attacker\share\dest\file.txt",
            r"C:\dest-sibling\file.txt",
            r"C:\other\file.txt",
        ] {
            assert_eq!(
                strip_base_prefix(base, Path::new(path)),
                None,
                "{path} should not be inside {}",
                base.display()
            );
        }
    }

    #[test]
    fn test_simplified_components_windows_unc_path() {
        let path = Path::new(r"\\server\share\foo\bar.txt");
        let components = simplified_components(path).unwrap();
        assert_eq!(components.len(), 2);
        assert_eq!(components[0], "foo");
        assert_eq!(components[1], "bar.txt");
    }
}