Skip to main content

rustferry_core/
naming.rs

1use sha2::{Digest, Sha256};
2use thiserror::Error;
3
4/// Validated names derived from the value passed to `cargo ferry new`.
5#[derive(Clone, Debug, Eq, PartialEq)]
6pub struct ProjectNames {
7    /// Directory name requested by the user.
8    pub directory_name: String,
9    /// Cargo-compatible package name.
10    pub crate_name: String,
11    /// Human-readable application name.
12    pub display_name: String,
13    /// Cross-platform application identifier.
14    pub application_identifier: String,
15}
16
17/// Project or application identifier validation failure.
18#[derive(Debug, Error, Eq, PartialEq)]
19pub enum NamingError {
20    /// The requested name cannot safely identify a child directory.
21    #[error("invalid project name: {reason}")]
22    InvalidProjectName {
23        /// Human-readable constraint that failed.
24        reason: String,
25    },
26    /// The application identifier cannot be used on every target platform.
27    #[error("invalid application identifier `{identifier}`: {reason}")]
28    InvalidApplicationIdentifier {
29        /// Rejected identifier.
30        identifier: String,
31        /// Human-readable constraint that failed.
32        reason: String,
33    },
34}
35
36/// Validate and derive all project names without touching the filesystem.
37///
38/// # Errors
39///
40/// Returns [`NamingError`] when the name is unsafe or the identifier is not portable.
41pub fn derive_project_names(
42    requested_name: &str,
43    identifier: Option<&str>,
44) -> Result<ProjectNames, NamingError> {
45    validate_project_name(requested_name)?;
46
47    let crate_name = crate_name(requested_name);
48    let display_name = display_name(requested_name);
49    let application_identifier = identifier.map_or_else(
50        || {
51            let suffix = crate_name
52                .chars()
53                .filter(char::is_ascii_alphanumeric)
54                .collect::<String>();
55            format!("org.rustferry.{suffix}")
56        },
57        ToOwned::to_owned,
58    );
59    validate_application_identifier(&application_identifier)?;
60
61    Ok(ProjectNames {
62        directory_name: requested_name.to_owned(),
63        crate_name,
64        display_name,
65        application_identifier,
66    })
67}
68
69fn validate_project_name(name: &str) -> Result<(), NamingError> {
70    if name.is_empty() {
71        return Err(invalid_name("the name is empty"));
72    }
73    if name.trim() != name {
74        return Err(invalid_name(
75            "leading or trailing whitespace is not allowed",
76        ));
77    }
78    if matches!(name, "." | "..") {
79        return Err(invalid_name("`.` and `..` are not project names"));
80    }
81    if name.contains(['/', '\\']) {
82        return Err(invalid_name("path separators are not allowed"));
83    }
84    if name.chars().any(char::is_control) {
85        return Err(invalid_name("control characters are not allowed"));
86    }
87    if name.contains(['<', '>', ':', '"', '|', '?', '*']) || name.ends_with('.') {
88        return Err(invalid_name(
89            "the name contains characters that are unsafe on supported filesystems",
90        ));
91    }
92    let windows_stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
93    if matches!(windows_stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
94        || windows_stem
95            .strip_prefix("COM")
96            .or_else(|| windows_stem.strip_prefix("LPT"))
97            .is_some_and(|number| {
98                matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
99            })
100    {
101        return Err(invalid_name("the name is reserved by Windows"));
102    }
103    Ok(())
104}
105
106fn invalid_name(reason: &str) -> NamingError {
107    NamingError::InvalidProjectName {
108        reason: reason.to_owned(),
109    }
110}
111
112fn crate_name(name: &str) -> String {
113    let mut output = String::with_capacity(name.len());
114    let mut separator = false;
115    for character in name.chars() {
116        if character.is_ascii_alphanumeric() {
117            if separator && !output.is_empty() {
118                output.push('-');
119            }
120            separator = false;
121            output.push(character.to_ascii_lowercase());
122        } else {
123            separator = true;
124        }
125    }
126    while output.ends_with('-') {
127        output.pop();
128    }
129    if output.is_empty() {
130        let digest = Sha256::digest(name.as_bytes());
131        output = format!("app-{}", hex::encode(&digest[..4]));
132    }
133    if output.as_bytes()[0].is_ascii_digit() {
134        output.insert_str(0, "app-");
135    }
136    if rust_keyword(&output) {
137        output.insert_str(0, "app-");
138    }
139    output
140}
141
142fn rust_keyword(value: &str) -> bool {
143    matches!(
144        value,
145        "as" | "break"
146            | "const"
147            | "continue"
148            | "crate"
149            | "else"
150            | "enum"
151            | "extern"
152            | "false"
153            | "fn"
154            | "for"
155            | "if"
156            | "impl"
157            | "in"
158            | "let"
159            | "loop"
160            | "match"
161            | "mod"
162            | "move"
163            | "mut"
164            | "pub"
165            | "ref"
166            | "return"
167            | "self"
168            | "Self"
169            | "static"
170            | "struct"
171            | "super"
172            | "trait"
173            | "true"
174            | "type"
175            | "unsafe"
176            | "use"
177            | "where"
178            | "while"
179            | "async"
180            | "await"
181            | "dyn"
182            | "abstract"
183            | "become"
184            | "box"
185            | "do"
186            | "final"
187            | "macro"
188            | "override"
189            | "priv"
190            | "typeof"
191            | "unsized"
192            | "virtual"
193            | "yield"
194            | "try"
195            | "gen"
196    )
197}
198
199fn display_name(name: &str) -> String {
200    let mut words = name
201        .split(['-', '_'])
202        .filter(|word| !word.is_empty())
203        .map(|word| {
204            let mut characters = word.chars();
205            characters.next().map_or_else(String::new, |first| {
206                first.to_uppercase().collect::<String>() + characters.as_str()
207            })
208        })
209        .collect::<Vec<_>>();
210    if words.is_empty() {
211        name.to_owned()
212    } else {
213        words.retain(|word| !word.is_empty());
214        words.join(" ")
215    }
216}
217
218/// Validate the conservative intersection of Android application IDs and Apple bundle IDs.
219///
220/// # Errors
221///
222/// Returns [`NamingError::InvalidApplicationIdentifier`] for a non-portable identifier.
223pub fn validate_application_identifier(identifier: &str) -> Result<(), NamingError> {
224    if identifier.len() > 255 {
225        return Err(invalid_identifier(identifier, "it exceeds 255 bytes"));
226    }
227    let segments = identifier.split('.').collect::<Vec<_>>();
228    if segments.len() < 3 {
229        return Err(invalid_identifier(
230            identifier,
231            "use at least three dot-separated segments, for example `com.example.app`",
232        ));
233    }
234    for segment in segments {
235        let mut characters = segment.chars();
236        let Some(first) = characters.next() else {
237            return Err(invalid_identifier(
238                identifier,
239                "empty segments are not allowed",
240            ));
241        };
242        if !first.is_ascii_lowercase() {
243            return Err(invalid_identifier(
244                identifier,
245                "each segment must start with a lowercase ASCII letter",
246            ));
247        }
248        if !characters.all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
249        {
250            return Err(invalid_identifier(
251                identifier,
252                "segments may contain only lowercase ASCII letters and digits",
253            ));
254        }
255    }
256    Ok(())
257}
258
259fn invalid_identifier(identifier: &str, reason: &str) -> NamingError {
260    NamingError::InvalidApplicationIdentifier {
261        identifier: identifier.to_owned(),
262        reason: reason.to_owned(),
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn derives_safe_names() {
272        let names = derive_project_names("Weather App", None).unwrap();
273        assert_eq!(names.crate_name, "weather-app");
274        assert_eq!(names.display_name, "Weather App");
275        assert_eq!(names.application_identifier, "org.rustferry.weatherapp");
276    }
277
278    #[test]
279    fn unicode_names_are_deterministic() {
280        let first = derive_project_names("Погода", None).unwrap();
281        let second = derive_project_names("Погода", None).unwrap();
282        assert_eq!(first.crate_name, second.crate_name);
283        assert!(first.crate_name.starts_with("app-"));
284    }
285
286    #[test]
287    fn rejects_path_traversal() {
288        assert!(derive_project_names("../weather", None).is_err());
289        assert!(derive_project_names("..", None).is_err());
290    }
291
292    #[test]
293    fn rejects_cross_platform_reserved_names() {
294        assert!(derive_project_names("NUL", None).is_err());
295        assert!(derive_project_names("weather?", None).is_err());
296        assert_eq!(
297            derive_project_names("fn", None).unwrap().crate_name,
298            "app-fn"
299        );
300    }
301
302    #[test]
303    fn rejects_unsafe_identifier() {
304        assert!(validate_application_identifier("com.Example.weather").is_err());
305        assert!(validate_application_identifier("com.example.weather_app").is_err());
306        assert!(validate_application_identifier("com.example.weather-app").is_err());
307        assert!(validate_application_identifier("example").is_err());
308    }
309}