Skip to main content

apk/types/
name.rs

1use nutype::nutype;
2
3use super::is_java_identifier;
4
5/// Why a [`PackageName`] was rejected.
6#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
7#[non_exhaustive]
8pub enum PackageNameError {
9    #[error("package name has {segments} segment(s); Android requires at least two")]
10    TooFewSegments { segments: usize },
11    #[error("package name segment {segment:?} is not an ASCII Java identifier without `$`")]
12    InvalidSegment { segment: String },
13}
14
15fn validate_package_name(value: &str) -> Result<(), PackageNameError> {
16    let segments = value.split('.').count();
17    if segments < 2 {
18        return Err(PackageNameError::TooFewSegments { segments });
19    }
20    match value.split('.').find(|s| !is_java_identifier(s) || s.contains('$')) {
21        Some(segment) => Err(PackageNameError::InvalidSegment { segment: segment.to_owned() }),
22        None => Ok(()),
23    }
24}
25
26/// An Android application ID such as `org.example.game`.
27#[nutype(
28    validate(with = validate_package_name, error = PackageNameError),
29    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
30)]
31pub struct PackageName(String);
32
33/// Why a [`ClassName`] was rejected.
34#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
35#[non_exhaustive]
36pub enum ClassNameError {
37    #[error("class name is empty")]
38    Empty,
39    #[error("class name segment {segment:?} is not an ASCII Java identifier")]
40    InvalidSegment { segment: String },
41}
42
43fn validate_class_name(value: &str) -> Result<(), ClassNameError> {
44    let body = value.strip_prefix('.').unwrap_or(value);
45    if body.is_empty() {
46        return Err(ClassNameError::Empty);
47    }
48    match body.split('.').find(|s| !is_java_identifier(s)) {
49        Some(segment) => Err(ClassNameError::InvalidSegment { segment: segment.to_owned() }),
50        None => Ok(()),
51    }
52}
53
54/// A Java class name such as `android.app.NativeActivity`.
55///
56/// A leading `.` (e.g. `.MainActivity`) is accepted; Android resolves it
57/// relative to the package name.
58#[nutype(
59    validate(with = validate_class_name, error = ClassNameError),
60    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
61)]
62pub struct ClassName(String);
63
64impl ClassName {
65    /// `android.app.NativeActivity`, the framework activity that hosts a native library.
66    pub fn native_activity() -> Self {
67        Self::try_new("android.app.NativeActivity").expect("valid class name")
68    }
69}
70
71/// Why a [`PermissionName`] was rejected.
72#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
73#[non_exhaustive]
74pub enum PermissionNameError {
75    #[error("permission name has {segments} segment(s); at least two are required")]
76    TooFewSegments { segments: usize },
77    #[error("permission name segment {segment:?} is not an ASCII Java identifier")]
78    InvalidSegment { segment: String },
79}
80
81fn validate_permission_name(value: &str) -> Result<(), PermissionNameError> {
82    let segments = value.split('.').count();
83    if segments < 2 {
84        return Err(PermissionNameError::TooFewSegments { segments });
85    }
86    match value.split('.').find(|s| !is_java_identifier(s)) {
87        Some(segment) => Err(PermissionNameError::InvalidSegment { segment: segment.to_owned() }),
88        None => Ok(()),
89    }
90}
91
92/// A permission name such as `android.permission.INTERNET`.
93#[nutype(
94    validate(with = validate_permission_name, error = PermissionNameError),
95    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
96)]
97pub struct PermissionName(String);
98
99impl PermissionName {
100    /// `android.permission.<name>`.
101    pub fn android(name: &str) -> Result<Self, PermissionNameError> {
102        Self::try_new(format!("android.permission.{name}"))
103    }
104}
105
106/// Why a [`LibraryName`] was rejected.
107#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
108#[non_exhaustive]
109pub enum LibraryNameError {
110    #[error("library name is empty")]
111    Empty,
112    #[error("library name is {len} bytes long; the limit is {max}")]
113    TooLong { len: usize, max: usize },
114    #[error("library name {name:?} includes the `lib` prefix and `.so` suffix; pass only the stem")]
115    IncludesFileAffixes { name: String },
116    #[error("library name contains {character:?}; only ASCII letters, digits, `_`, `-`, `.`, and `+` are allowed")]
117    InvalidCharacter { character: char },
118    #[error("file name {file_name:?} does not have the form `lib<name>.so`")]
119    NotALibraryFileName { file_name: String },
120}
121
122const MAX_LIBRARY_NAME: usize = 200;
123
124fn validate_library_name(value: &str) -> Result<(), LibraryNameError> {
125    if value.is_empty() {
126        return Err(LibraryNameError::Empty);
127    }
128    if value.len() > MAX_LIBRARY_NAME {
129        return Err(LibraryNameError::TooLong { len: value.len(), max: MAX_LIBRARY_NAME });
130    }
131    if value.starts_with("lib") && value.ends_with(".so") {
132        return Err(LibraryNameError::IncludesFileAffixes { name: value.to_owned() });
133    }
134    match value.chars().find(|&c| !(c.is_ascii_alphanumeric() || "_-.+".contains(c))) {
135        Some(character) => Err(LibraryNameError::InvalidCharacter { character }),
136        None => Ok(()),
137    }
138}
139
140/// A native library name without its `lib` prefix and `.so` suffix:
141/// `c++_shared` names `libc++_shared.so`.
142#[nutype(
143    validate(with = validate_library_name, error = LibraryNameError),
144    derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, AsRef, Deref, FromStr, TryFrom, Into)
145)]
146pub struct LibraryName(String);
147
148impl LibraryName {
149    /// Extracts the name from a file name of the form `lib<name>.so`.
150    pub fn from_file_name(file_name: &str) -> Result<Self, LibraryNameError> {
151        let stem = file_name.strip_prefix("lib").and_then(|rest| rest.strip_suffix(".so"));
152        let stem = stem.ok_or_else(|| LibraryNameError::NotALibraryFileName { file_name: file_name.to_owned() })?;
153        Self::try_new(stem)
154    }
155
156    /// The file name stored in the APK: `lib<name>.so`.
157    pub fn file_name(&self) -> String {
158        format!("lib{}.so", self.as_ref())
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn package_names() {
168        for valid in ["org.example", "a.b.c", "com.Example_1.app_"] {
169            assert!(PackageName::try_new(valid).is_ok(), "{valid}");
170        }
171        assert_eq!(PackageName::try_new("single"), Err(PackageNameError::TooFewSegments { segments: 1 }));
172        assert_eq!(
173            PackageName::try_new("org.1example"),
174            Err(PackageNameError::InvalidSegment { segment: "1example".into() })
175        );
176        for invalid in ["org..example", ".org.example", "org.ex-ample", "org.ex$ample", "org.example."] {
177            assert!(PackageName::try_new(invalid).is_err(), "{invalid}");
178        }
179    }
180
181    #[test]
182    fn class_names_accept_relative_names() {
183        assert!(ClassName::try_new(".MainActivity").is_ok());
184        assert!(ClassName::try_new("org.example.Main$Inner").is_ok());
185        assert_eq!(ClassName::try_new("."), Err(ClassNameError::Empty));
186        assert!(ClassName::try_new("org.example.").is_err());
187    }
188
189    #[test]
190    fn library_names_cover_ndk_conventions() {
191        assert_eq!(LibraryName::try_new("c++_shared").unwrap().file_name(), "libc++_shared.so");
192        assert_eq!(LibraryName::from_file_name("libmain.so").unwrap().as_ref(), "main");
193        assert_eq!(
194            LibraryName::try_new("libmain.so"),
195            Err(LibraryNameError::IncludesFileAffixes { name: "libmain.so".into() })
196        );
197        assert_eq!(LibraryName::try_new("a/b"), Err(LibraryNameError::InvalidCharacter { character: '/' }));
198        assert!(matches!(LibraryName::from_file_name("main.so"), Err(LibraryNameError::NotALibraryFileName { .. })));
199    }
200}