use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::NativeError;
pub const SCHEMA_VERSION: u32 = 1;
pub const TARGETS: &[&str] = &["windows", "android"];
pub const MIN_ANDROID_SDK: u32 = 24;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NativeConfig {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
pub schema_ref: Option<String>,
pub schema: u32,
pub product_name: String,
pub identifier: String,
pub version: String,
pub targets: Vec<String>,
#[serde(default)]
pub window: WindowConfig,
#[serde(default)]
pub android: AndroidConfig,
#[serde(default)]
pub bundle: BundleConfig,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<String>,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub scaffold: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct WindowConfig {
pub title: String,
pub width: u32,
pub height: u32,
#[serde(default = "yes")]
pub resizable: bool,
}
impl Default for WindowConfig {
fn default() -> Self {
WindowConfig {
title: "Rahti".to_string(),
width: 1200,
height: 800,
resizable: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AndroidConfig {
pub min_sdk: u32,
}
impl Default for AndroidConfig {
fn default() -> Self {
AndroidConfig {
min_sdk: MIN_ANDROID_SDK,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BundleConfig {
pub icons: String,
}
impl Default for BundleConfig {
fn default() -> Self {
BundleConfig {
icons: "native/icons".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum DatabaseMode {
SqliteLocal,
Remote,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DatabaseConfig {
pub mode: DatabaseMode,
}
impl Default for DatabaseConfig {
fn default() -> Self {
DatabaseConfig {
mode: DatabaseMode::SqliteLocal,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AuthConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookie_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SecurityConfig {
#[serde(default = "yes")]
pub loopback_token: bool,
#[serde(default = "default_csp")]
pub csp: String,
}
impl Default for SecurityConfig {
fn default() -> Self {
SecurityConfig {
loopback_token: true,
csp: default_csp(),
}
}
}
pub fn default_csp() -> String {
"default-src 'self'; \
script-src 'self' 'unsafe-eval'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob:; \
font-src 'self' data:; \
connect-src 'self' ws: http://127.0.0.1:*; \
frame-ancestors 'none'; \
object-src 'none'; \
base-uri 'self'"
.to_string()
}
const SUPERSEDED_CSPS: &[&str] = &[
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
img-src 'self' data: blob:; font-src 'self' data:; \
connect-src 'self' ws: http://127.0.0.1:*; frame-ancestors 'none'; \
object-src 'none'; base-uri 'self'",
];
pub fn superseded_csp(csp: &str) -> bool {
SUPERSEDED_CSPS.contains(&csp)
}
fn yes() -> bool {
true
}
impl NativeConfig {
pub fn new(product_name: &str, identifier: &str, version: &str, targets: &[&str]) -> Self {
NativeConfig {
schema_ref: Some("./rahti.native.schema.json".to_string()),
schema: SCHEMA_VERSION,
product_name: product_name.to_string(),
identifier: identifier.to_string(),
version: version.to_string(),
targets: targets.iter().map(|t| t.to_string()).collect(),
window: WindowConfig {
title: product_name.to_string(),
..WindowConfig::default()
},
android: AndroidConfig::default(),
bundle: BundleConfig::default(),
database: DatabaseConfig::default(),
auth: AuthConfig::default(),
local: None,
security: SecurityConfig::default(),
scaffold: BTreeMap::new(),
}
}
pub fn load(path: &Path) -> Result<Self, NativeError> {
let text = std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
NativeError::at(
"config",
path,
"this project has no native configuration.\n \
Create one with:\n \
cargo rahti native init --identifier com.example.myapp --windows",
)
} else {
NativeError::io("config", path, e)
}
})?;
Self::parse(&text).map_err(|mut e| {
e.path = Some(path.to_path_buf());
e
})
}
pub fn parse(text: &str) -> Result<Self, NativeError> {
let mut config: NativeConfig = serde_json::from_str(text).map_err(|e| {
NativeError::new("config", format!("rahti.native.json is not valid: {e}"))
})?;
config.migrate();
config.validate()?;
Ok(config)
}
fn migrate(&mut self) {
if superseded_csp(&self.security.csp) {
self.security.csp = default_csp();
}
}
pub fn to_json(&self) -> String {
let mut text = serde_json::to_string_pretty(self).expect("a configuration serializes");
text.push('\n');
text
}
pub fn validate(&self) -> Result<(), NativeError> {
let fail = |message: String| NativeError::new("config", message);
if self.schema != SCHEMA_VERSION {
return Err(fail(format!(
"rahti.native.json has `schema` {}, and this tool understands {SCHEMA_VERSION}.\n \
Upgrade cargo-rahti-native, or regenerate the file with `cargo rahti native init`.",
self.schema
)));
}
check_product_name(&self.product_name).map_err(fail)?;
check_identifier(&self.identifier).map_err(fail)?;
check_version(&self.version).map_err(fail)?;
if self.targets.is_empty() {
return Err(fail(
"`targets` is empty, so there is nothing to build.\n \
Add \"windows\", \"android\", or both."
.to_string(),
));
}
for target in &self.targets {
if !TARGETS.contains(&target.as_str()) {
return Err(fail(format!(
"`{target}` is not a native target. Rahti packages {}.",
TARGETS.join(" and ")
)));
}
}
if self.window.width == 0 || self.window.height == 0 {
return Err(fail(
"a window with a zero dimension has nothing to show.".to_string(),
));
}
if self.android.min_sdk < MIN_ANDROID_SDK {
return Err(fail(format!(
"`android.minSdk` is {}, and Tauri 2 needs at least {MIN_ANDROID_SDK}.",
self.android.min_sdk
)));
}
if let Some(cookie) = &self.auth.cookie_name {
check_cookie_name(cookie).map_err(fail)?;
}
if self.security.csp.trim().is_empty() {
return Err(fail(
"`security.csp` is empty. A package that serves no Content-Security-Policy \
puts an XSS in reach of the native command bridge — set a policy, or remove \
the field to take the default."
.to_string(),
));
}
let scripts = self
.security
.csp
.split(';')
.map(str::trim)
.find(|directive| directive.starts_with("script-src"));
if let Some(scripts) = scripts
&& !scripts.contains("'unsafe-eval'")
{
return Err(fail(format!(
"`security.csp` has `{scripts}`, and PulsePoint cannot run under it.\n \
It compiles the expressions in a reactive block at runtime, with \
`new Function` — which a Content-Security-Policy counts as evaluating a \
string as JavaScript.\n \
Without `'unsafe-eval'` the page renders and every binding on it is \
dead, reporting an EvalError from inside the runtime bundle.\n \
Add `'unsafe-eval'` to `script-src`. It stays narrow: `script-src` still \
refuses every *source* but this origin, so injected markup cannot load \
an attacker's file."
)));
}
Ok(())
}
pub fn builds(&self, target: &str) -> bool {
self.targets.iter().any(|t| t == target)
}
pub fn android_version_code(&self) -> u32 {
let mut parts = self
.version
.split('.')
.map(|p| p.parse::<u32>().unwrap_or(0));
let major = parts.next().unwrap_or(0);
let minor = parts.next().unwrap_or(0);
let patch = parts.next().unwrap_or(0);
major * 10_000 + minor * 100 + patch
}
}
pub fn check_identifier(identifier: &str) -> Result<(), String> {
let advice = " An identifier is reverse-DNS and has to be a legal Android package name: \
at least two segments, each starting with a letter and made of letters, \
digits and `_`. No hyphens.\n \
For example: com.example.myapp";
if identifier.trim() != identifier || identifier.is_empty() {
return Err(format!(
"`{identifier}` is not an application identifier.\n{advice}"
));
}
let segments: Vec<&str> = identifier.split('.').collect();
if segments.len() < 2 {
return Err(format!(
"`{identifier}` has one segment, and an identifier needs at least two.\n{advice}"
));
}
for segment in &segments {
if segment.is_empty() {
return Err(format!("`{identifier}` has an empty segment.\n{advice}"));
}
if !segment.starts_with(|c: char| c.is_ascii_alphabetic()) {
return Err(format!(
"`{identifier}`: the segment `{segment}` does not start with a letter.\n{advice}"
));
}
if !segment
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(format!(
"`{identifier}`: the segment `{segment}` has a character that is not a letter, \
a digit or `_`.\n{advice}"
));
}
if JAVA_KEYWORDS.contains(segment) {
return Err(format!(
"`{identifier}`: `{segment}` is a Java keyword, which an Android package name \
cannot contain.\n{advice}"
));
}
}
if identifier == "com.tauri.dev" {
return Err(
"`com.tauri.dev` is Tauri's placeholder identifier, and every application using it \
would replace every other one on the device.\n \
Use your own reverse-DNS identifier, for example com.example.myapp."
.to_string(),
);
}
Ok(())
}
const JAVA_KEYWORDS: &[&str] = &[
"abstract",
"assert",
"boolean",
"break",
"byte",
"case",
"catch",
"char",
"class",
"const",
"continue",
"default",
"do",
"double",
"else",
"enum",
"extends",
"final",
"finally",
"float",
"for",
"goto",
"if",
"implements",
"import",
"instanceof",
"int",
"interface",
"long",
"native",
"new",
"package",
"private",
"protected",
"public",
"return",
"short",
"static",
"strictfp",
"super",
"switch",
"synchronized",
"this",
"throw",
"throws",
"transient",
"try",
"void",
"volatile",
"while",
];
pub fn check_product_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err(
"`productName` is empty, and it is what the installed application is \
called."
.to_string(),
);
}
if name.trim() != name {
return Err(format!(
"`productName` is `{name}`, which has leading or trailing whitespace. It becomes a \
filename, where that does not survive."
));
}
const FORBIDDEN: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
if let Some(bad) = name
.chars()
.find(|c| FORBIDDEN.contains(c) || c.is_control())
{
return Err(format!(
"`productName` contains `{bad}`, which cannot be in a filename — and the product \
name becomes one."
));
}
Ok(())
}
pub fn check_version(version: &str) -> Result<(), String> {
let parts: Vec<&str> = version.split('.').collect();
let numeric = parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
if !numeric {
return Err(format!(
"`version` is `{version}`, and a native package needs `major.minor.patch` with all \
three numeric.\n \
A Windows installer version is three numbers, and Google Play orders releases by an \
integer derived from them — a pre-release suffix has nowhere to go in either."
));
}
for part in parts {
if part.parse::<u32>().is_err() {
return Err(format!(
"`version` is `{version}`, and `{part}` is too large."
));
}
}
Ok(())
}
fn check_cookie_name(name: &str) -> Result<(), String> {
const SEPARATORS: &[char] = &[
'(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ',
];
if name.is_empty() {
return Err("`auth.cookieName` is empty.".to_string());
}
if name
.chars()
.any(|c| c.is_control() || SEPARATORS.contains(&c) || !c.is_ascii())
{
return Err(format!(
"`auth.cookieName` is `{name}`, which is not a legal cookie name.\n \
It has to be a token: letters, digits, and `-_.~!#$%&'*+^|`."
));
}
Ok(())
}