use nutype::nutype;
pub const MAX_ARCHIVE_PATH_LEN: usize = u16::MAX as usize;
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ArchivePathError {
#[error("archive path is empty")]
Empty,
#[error("archive path is {len} bytes; ZIP entry names are limited to {max}", max = MAX_ARCHIVE_PATH_LEN)]
TooLong { len: usize },
#[error("archive path contains forbidden character {character:?}")]
ForbiddenCharacter { character: char },
#[error("archive path component {component:?} is empty or a `.`/`..` traversal")]
InvalidComponent { component: String },
#[error("archive path begins with drive-like component {component:?}")]
DriveLikeComponent { component: String },
}
fn validate_archive_path(value: &str) -> Result<(), ArchivePathError> {
if value.is_empty() {
return Err(ArchivePathError::Empty);
}
if value.len() > MAX_ARCHIVE_PATH_LEN {
return Err(ArchivePathError::TooLong { len: value.len() });
}
if let Some(character) = value.chars().find(|&c| c.is_control() || c == '\\') {
return Err(ArchivePathError::ForbiddenCharacter { character });
}
if let Some(component) = value.split('/').find(|c| c.is_empty() || *c == "." || *c == "..") {
return Err(ArchivePathError::InvalidComponent { component: component.to_owned() });
}
match value.split('/').next() {
Some(first) if first.contains(':') => Err(ArchivePathError::DriveLikeComponent { component: first.to_owned() }),
_ => Ok(()),
}
}
#[nutype(
validate(with = validate_archive_path, error = ArchivePathError),
derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
)]
pub struct ArchivePath(String);
impl ArchivePath {
pub fn manifest() -> Self {
Self::known("AndroidManifest.xml")
}
pub fn resource_table() -> Self {
Self::known("resources.arsc")
}
pub(crate) fn known(path: &str) -> Self {
Self::try_new(path).expect("built-in archive path is valid")
}
pub fn join(&self, relative: &str) -> Result<Self, ArchivePathError> {
Self::try_new(format!("{self}/{relative}"))
}
pub fn file_name(&self) -> &str {
self.rsplit('/').next().unwrap_or_default()
}
pub fn extension(&self) -> Option<&str> {
let name = self.file_name();
name.rfind('.').filter(|&dot| dot > 0).map(|dot| &name[dot + 1..])
}
pub fn is_in(&self, dir: &str) -> bool {
let path = self.as_str();
path.len() > dir.len() && path.as_bytes()[dir.len()] == b'/' && path[..dir.len()].eq_ignore_ascii_case(dir)
}
pub fn as_str(&self) -> &str {
self.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_traversal_and_platform_specific_paths() {
use ArchivePathError::*;
let cases = [
("", Empty),
("/abs", InvalidComponent { component: "".into() }),
("a//b", InvalidComponent { component: "".into() }),
("a/./b", InvalidComponent { component: ".".into() }),
("../x", InvalidComponent { component: "..".into() }),
("dir/", InvalidComponent { component: "".into() }),
("a\\b", ForbiddenCharacter { character: '\\' }),
("a\nb", ForbiddenCharacter { character: '\n' }),
("C:/x", DriveLikeComponent { component: "C:".into() }),
];
for (path, error) in cases {
assert_eq!(ArchivePath::try_new(path), Err(error), "{path:?}");
}
assert_eq!(ArchivePath::try_new("a".repeat(65_536)), Err(TooLong { len: 65_536 }));
}
#[test]
fn accepts_and_inspects_regular_paths() {
let path = ArchivePath::try_new("assets/a:b/level.tar.gz").unwrap();
assert_eq!(path.file_name(), "level.tar.gz");
assert_eq!(path.extension(), Some("gz"));
assert!(path.is_in("assets"));
assert!(!path.is_in("asset"));
assert!(ArchivePath::try_new("META-INF/CERT.SF").unwrap().is_in("meta-inf"));
assert_eq!(ArchivePath::try_new(".hidden").unwrap().extension(), None);
}
}