uv_extract/
lib.rs

1use std::sync::LazyLock;
2
3pub use error::Error;
4use regex::Regex;
5pub use sync::*;
6use uv_static::EnvVars;
7
8mod error;
9pub mod hash;
10pub mod stream;
11mod sync;
12mod vendor;
13
14static CONTROL_CHARACTERS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\p{C}").unwrap());
15static REPLACEMENT_CHARACTER: &str = "\u{FFFD}";
16
17/// Validate that a given filename (e.g. reported by a ZIP archive's
18/// local file entries or central directory entries) is "safe" to use.
19///
20/// "Safe" in this context doesn't refer to directory traversal
21/// risk, but whether we believe that other ZIP implementations
22/// handle the name correctly and consistently.
23///
24/// Specifically, we want to avoid names that:
25///
26/// - Contain *any* non-printable characters
27/// - Are empty
28///
29/// In the future, we may also want to check for names that contain
30/// leading/trailing whitespace, or names that are exceedingly long.
31pub(crate) fn validate_archive_member_name(name: &str) -> Result<(), Error> {
32    if name.is_empty() {
33        return Err(Error::EmptyFilename);
34    }
35
36    match CONTROL_CHARACTERS_RE.replace_all(name, REPLACEMENT_CHARACTER) {
37        // No replacements mean no control characters.
38        std::borrow::Cow::Borrowed(_) => Ok(()),
39        std::borrow::Cow::Owned(sanitized) => Err(Error::UnacceptableFilename {
40            filename: sanitized,
41        }),
42    }
43}
44
45/// Returns `true` if ZIP validation is disabled.
46pub(crate) fn insecure_no_validate() -> bool {
47    // TODO(charlie) Parse this in `EnvironmentOptions`.
48    let Some(value) = std::env::var_os(EnvVars::UV_INSECURE_NO_ZIP_VALIDATION) else {
49        return false;
50    };
51    let Some(value) = value.to_str() else {
52        return false;
53    };
54    matches!(
55        value.to_lowercase().as_str(),
56        "y" | "yes" | "t" | "true" | "on" | "1"
57    )
58}
59
60#[cfg(test)]
61mod tests {
62    #[test]
63    fn test_validate_archive_member_name() {
64        for (testcase, ok) in &[
65            // Valid cases.
66            ("normal.txt", true),
67            ("__init__.py", true),
68            ("fine i guess.py", true),
69            ("🌈.py", true),
70            // Invalid cases.
71            ("", false),
72            ("new\nline.py", false),
73            ("carriage\rreturn.py", false),
74            ("tab\tcharacter.py", false),
75            ("null\0byte.py", false),
76            ("control\x01code.py", false),
77            ("control\x02code.py", false),
78            ("control\x03code.py", false),
79            ("control\x04code.py", false),
80            ("backspace\x08code.py", false),
81            ("delete\x7fcode.py", false),
82        ] {
83            assert_eq!(
84                super::validate_archive_member_name(testcase).is_ok(),
85                *ok,
86                "testcase: {testcase}"
87            );
88        }
89    }
90
91    #[test]
92    fn test_unacceptable_filename_error_replaces_control_characters() {
93        let err = super::validate_archive_member_name("bad\nname").unwrap_err();
94        match err {
95            super::Error::UnacceptableFilename { filename } => {
96                assert_eq!(filename, "bad�name");
97            }
98            _ => panic!("expected UnacceptableFilename error"),
99        }
100    }
101}