Skip to main content

uv_extract/
lib.rs

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