Skip to main content

uv_extract/
lib.rs

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