//! Contains logic for generating new file names for Instances based on their //! middleware. use std::borrow::Cow; use anyhow::Context; use rbx_dom_weak::Instance; use crate::{snapshot::InstanceWithMeta, snapshot_middleware::Middleware}; pub fn name_for_inst<'a>( middleware: Middleware, new_inst: &'a Instance, old_inst: Option>, ) -> anyhow::Result> { if let Some(old_inst) = old_inst { if let Some(source) = old_inst.metadata().relevant_paths.first() { source .file_name() .and_then(|s| s.to_str()) .map(Cow::Borrowed) .context("sources on the file system should be valid unicode and not be stubs") } else { // This is technically not /always/ true, but we want to avoid // running syncback on anything that has no instigating source // anyway. anyhow::bail!( "members of 'old' trees should have an instigating source. Somehow, {} did not.", old_inst.name(), ); } } else { Ok(match middleware { Middleware::Dir | Middleware::CsvDir | Middleware::ServerScriptDir | Middleware::ClientScriptDir | Middleware::ModuleScriptDir => { if validate_file_name(&new_inst.name).is_err() { Cow::Owned(slugify_name(&new_inst.name)) } else { Cow::Borrowed(&new_inst.name) } } _ => { let extension = extension_for_middleware(middleware); let slugified; let final_name = if validate_file_name(&new_inst.name).is_err() { slugified = slugify_name(&new_inst.name); &slugified } else { &new_inst.name }; Cow::Owned(format!("{final_name}.{extension}")) } }) } } /// Returns the extension a provided piece of middleware is supposed to use. pub fn extension_for_middleware(middleware: Middleware) -> &'static str { match middleware { Middleware::Csv => "csv", Middleware::JsonModel => "model.json", Middleware::Json => "json", Middleware::ServerScript => "server.luau", Middleware::ClientScript => "client.luau", Middleware::ModuleScript => "luau", Middleware::PluginScript => "plugin.luau", Middleware::Project => "project.json", Middleware::Rbxm => "rbxm", Middleware::Rbxmx => "rbxmx", Middleware::Toml => "toml", Middleware::Text => "txt", Middleware::Yaml => "yml", Middleware::LegacyServerScript | Middleware::LegacyClientScript | Middleware::RunContextServerScript | Middleware::RunContextClientScript => { todo!("syncback does not work on the middleware {middleware:?} yet") } // These are manually specified and not `_` to guard against future // middleware additions missing this function. Middleware::Ignore => unimplemented!("syncback does not work on Ignore middleware"), Middleware::Dir | Middleware::CsvDir | Middleware::ServerScriptDir | Middleware::ClientScriptDir | Middleware::ModuleScriptDir => { unimplemented!("directory middleware requires special treatment") } } } /// A list of file names that are not valid on Windows. const INVALID_WINDOWS_NAMES: [&str; 22] = [ "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; /// A list of all characters that are outright forbidden to be included /// in a file's name. const FORBIDDEN_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '|', '?', '*', '\\']; /// Slugifies a name by replacing forbidden characters with underscores /// and ensuring the result is a valid file name pub fn slugify_name(name: &str) -> String { let mut result = String::with_capacity(name.len()); for ch in name.chars() { if FORBIDDEN_CHARS.contains(&ch) { result.push('_'); } else { result.push(ch); } } // Handle Windows reserved names by appending an underscore let result_lower = result.to_lowercase(); for forbidden in INVALID_WINDOWS_NAMES { if result_lower == forbidden.to_lowercase() { result.push('_'); break; } } while result.ends_with(' ') || result.ends_with('.') { result.pop(); } if result.is_empty() || result.chars().all(|c| c == '_') { result = "instance".to_string(); } result } /// Validates a provided file name to ensure it's allowed on the file system. An /// error is returned if the name isn't allowed, indicating why. /// This takes into account rules for Windows, MacOS, and Linux. /// /// In practice however, these broadly overlap so the only unexpected behavior /// is Windows, where there are 22 reserved names. pub fn validate_file_name>(name: S) -> anyhow::Result<()> { let str = name.as_ref(); if str.ends_with(' ') { anyhow::bail!("file names cannot end with a space") } if str.ends_with('.') { anyhow::bail!("file names cannot end with '.'") } for char in str.chars() { if FORBIDDEN_CHARS.contains(&char) { anyhow::bail!("file names cannot contain <, >, :, \", /, |, ?, *, or \\") } else if char.is_control() { anyhow::bail!("file names cannot contain control characters") } } for forbidden in INVALID_WINDOWS_NAMES { if str == forbidden { anyhow::bail!("files cannot be named {str}") } } Ok(()) }