1use nutype::nutype;
2
3pub const MAX_ARCHIVE_PATH_LEN: usize = u16::MAX as usize;
5
6#[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#[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 pub fn manifest() -> Self {
55 Self::known("AndroidManifest.xml")
56 }
57
58 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 pub fn join(&self, relative: &str) -> Result<Self, ArchivePathError> {
69 Self::try_new(format!("{self}/{relative}"))
70 }
71
72 pub fn file_name(&self) -> &str {
74 self.rsplit('/').next().unwrap_or_default()
75 }
76
77 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 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}