Skip to main content

apk/types/
path.rs

1use nutype::nutype;
2
3/// The longest name a classic ZIP entry can have.
4pub const MAX_ARCHIVE_PATH_LEN: usize = u16::MAX as usize;
5
6/// Why an [`ArchivePath`] was rejected.
7#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
8#[non_exhaustive]
9pub enum ArchivePathError {
10    #[error("archive path is empty")]
11    Empty,
12    #[error("archive path is {len} bytes; ZIP entry names are limited to {max}", max = MAX_ARCHIVE_PATH_LEN)]
13    TooLong { len: usize },
14    #[error("archive path contains forbidden character {character:?}")]
15    ForbiddenCharacter { character: char },
16    #[error("archive path component {component:?} is empty or a `.`/`..` traversal")]
17    InvalidComponent { component: String },
18    #[error("archive path begins with drive-like component {component:?}")]
19    DriveLikeComponent { component: String },
20}
21
22fn validate_archive_path(value: &str) -> Result<(), ArchivePathError> {
23    if value.is_empty() {
24        return Err(ArchivePathError::Empty);
25    }
26    if value.len() > MAX_ARCHIVE_PATH_LEN {
27        return Err(ArchivePathError::TooLong { len: value.len() });
28    }
29    if let Some(character) = value.chars().find(|&c| c.is_control() || c == '\\') {
30        return Err(ArchivePathError::ForbiddenCharacter { character });
31    }
32    if let Some(component) = value.split('/').find(|c| c.is_empty() || *c == "." || *c == "..") {
33        return Err(ArchivePathError::InvalidComponent { component: component.to_owned() });
34    }
35    match value.split('/').next() {
36        Some(first) if first.contains(':') => Err(ArchivePathError::DriveLikeComponent { component: first.to_owned() }),
37        _ => Ok(()),
38    }
39}
40
41/// A safe, relative ZIP entry path such as `assets/levels/1.bin`.
42///
43/// Paths use `/` separators and cannot be absolute or contain empty, `.`, or
44/// `..` components, backslashes, control characters, or a drive-like first
45/// component, so extracting an archive cannot write outside its directory.
46#[nutype(
47    validate(with = validate_archive_path, error = ArchivePathError),
48    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
49)]
50pub struct ArchivePath(String);
51
52impl ArchivePath {
53    /// `AndroidManifest.xml`.
54    pub fn manifest() -> Self {
55        Self::known("AndroidManifest.xml")
56    }
57
58    /// `resources.arsc`.
59    pub fn resource_table() -> Self {
60        Self::known("resources.arsc")
61    }
62
63    pub(crate) fn known(path: &str) -> Self {
64        Self::try_new(path).expect("built-in archive path is valid")
65    }
66
67    /// Joins a relative path below this one.
68    pub fn join(&self, relative: &str) -> Result<Self, ArchivePathError> {
69        Self::try_new(format!("{self}/{relative}"))
70    }
71
72    /// The final path component.
73    pub fn file_name(&self) -> &str {
74        self.rsplit('/').next().unwrap_or_default()
75    }
76
77    /// The extension of the file name, without the dot.
78    pub fn extension(&self) -> Option<&str> {
79        let name = self.file_name();
80        name.rfind('.').filter(|&dot| dot > 0).map(|dot| &name[dot + 1..])
81    }
82
83    /// Whether this path lies inside directory `dir` (ASCII case-insensitive).
84    pub fn is_in(&self, dir: &str) -> bool {
85        let path = self.as_str();
86        path.len() > dir.len() && path.as_bytes()[dir.len()] == b'/' && path[..dir.len()].eq_ignore_ascii_case(dir)
87    }
88
89    pub fn as_str(&self) -> &str {
90        self.as_ref()
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn rejects_traversal_and_platform_specific_paths() {
100        use ArchivePathError::*;
101        let cases = [
102            ("", Empty),
103            ("/abs", InvalidComponent { component: "".into() }),
104            ("a//b", InvalidComponent { component: "".into() }),
105            ("a/./b", InvalidComponent { component: ".".into() }),
106            ("../x", InvalidComponent { component: "..".into() }),
107            ("dir/", InvalidComponent { component: "".into() }),
108            ("a\\b", ForbiddenCharacter { character: '\\' }),
109            ("a\nb", ForbiddenCharacter { character: '\n' }),
110            ("C:/x", DriveLikeComponent { component: "C:".into() }),
111        ];
112        for (path, error) in cases {
113            assert_eq!(ArchivePath::try_new(path), Err(error), "{path:?}");
114        }
115        assert_eq!(ArchivePath::try_new("a".repeat(65_536)), Err(TooLong { len: 65_536 }));
116    }
117
118    #[test]
119    fn accepts_and_inspects_regular_paths() {
120        let path = ArchivePath::try_new("assets/a:b/level.tar.gz").unwrap();
121        assert_eq!(path.file_name(), "level.tar.gz");
122        assert_eq!(path.extension(), Some("gz"));
123        assert!(path.is_in("assets"));
124        assert!(!path.is_in("asset"));
125        assert!(ArchivePath::try_new("META-INF/CERT.SF").unwrap().is_in("meta-inf"));
126        assert_eq!(ArchivePath::try_new(".hidden").unwrap().extension(), None);
127    }
128}