Skip to main content

rahti_native/
config.rs

1//! `rahti.native.json` — what a project's native packages are called.
2//!
3//! Separate from `rahti.config.json` for the reason native support is opt-in
4//! at all: a web project should not carry a file describing packages it does
5//! not build, and `cargo rahti native init` is what puts this one there.
6//!
7//! ## What it holds, and what it must not
8//!
9//! Names, sizes and identifiers — the things every build of the application
10//! produces the same values for, which is exactly what belongs in a committed
11//! file.
12//!
13//! Not: signing passwords, keystore paths that carry credentials, the
14//! `AUTH_SECRET`, or anything per-installation. Signing is configured through
15//! the environment (see `native-packaging.md`), and the session key is
16//! generated on the device at first launch — see [`crate::session_secret`].
17//!
18//! `auth.cookieName` is the one value that looks like it might be a credential
19//! and is not. A cookie *name* is public: it is in every response header the
20//! application sends. It is recorded so that a packaged build keeps the
21//! per-project name `cargo rahti new` generated instead of falling back to the
22//! framework default, which would be a different cookie and therefore a
23//! different session. `AUTH_COOKIE_NAME` still comes from the environment
24//! everywhere else; this is what puts it in the environment of a process that
25//! has no `.env` to read.
26//!
27//! ## Validation before expense
28//!
29//! Everything here is checked before a build starts. An Android build that
30//! fails on a malformed application identifier fails after Gradle has been
31//! downloaded, a Rust target has been compiled and several minutes have
32//! passed. The same failure costs nothing if it happens at the point the file
33//! is read.
34
35use std::collections::BTreeMap;
36use std::path::Path;
37
38use serde::{Deserialize, Serialize};
39
40use crate::error::NativeError;
41
42/// The version of this file format that this build understands.
43pub const SCHEMA_VERSION: u32 = 1;
44
45/// The `--target` values `cargo rahti native` accepts.
46pub const TARGETS: &[&str] = &["windows", "android"];
47
48/// The lowest Android API level Tauri 2 supports.
49pub const MIN_ANDROID_SDK: u32 = 24;
50
51/// A project's native packaging configuration.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "camelCase", deny_unknown_fields)]
54pub struct NativeConfig {
55    /// Editor support. Written by `init`, ignored on read.
56    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
57    pub schema_ref: Option<String>,
58
59    /// The format version. Refused rather than guessed at when it is not one
60    /// this build knows: a field that changed meaning is worse than a file
61    /// that will not load.
62    pub schema: u32,
63
64    /// What the installed application is called.
65    pub product_name: String,
66
67    /// Reverse-DNS. The Windows bundle identity and the Android package name.
68    pub identifier: String,
69
70    /// `major.minor.patch`. Numeric because both platforms require it.
71    pub version: String,
72
73    /// Which packages this project builds.
74    pub targets: Vec<String>,
75
76    #[serde(default)]
77    pub window: WindowConfig,
78
79    #[serde(default)]
80    pub android: AndroidConfig,
81
82    #[serde(default)]
83    pub bundle: BundleConfig,
84
85    #[serde(default)]
86    pub database: DatabaseConfig,
87
88    #[serde(default)]
89    pub auth: AuthConfig,
90
91    /// Depend on a Rahti checkout by path rather than by version.
92    ///
93    /// For working on the framework itself, and the same idea as
94    /// `cargo rahti new --local`. Recorded rather than passed each time
95    /// because `native/Cargo.toml` is regenerated from this file, and a run
96    /// that forgot the flag would quietly move the shell onto a published
97    /// version of a crate that is being changed locally.
98    ///
99    /// The path is relative to the project root, or absolute.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub local: Option<String>,
102
103    #[serde(default)]
104    pub security: SecurityConfig,
105
106    /// Content hashes of the generated files under `native/`, written by
107    /// `cargo rahti native init`.
108    ///
109    /// How a later run knows which of them you have edited — and therefore
110    /// which it must leave alone. Bookkeeping rather than configuration: it is
111    /// in this file because this file is already the one a project commits,
112    /// and a second file holding nine hashes would be a second file.
113    ///
114    /// Empty is not written out, so a hand-written configuration does not
115    /// grow a section it never asked for.
116    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
117    pub scaffold: BTreeMap<String, String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122pub struct WindowConfig {
123    pub title: String,
124    pub width: u32,
125    pub height: u32,
126    /// Whether the window may be resized. Ignored on Android, which has no
127    /// window to resize.
128    #[serde(default = "yes")]
129    pub resizable: bool,
130}
131
132impl Default for WindowConfig {
133    fn default() -> Self {
134        WindowConfig {
135            title: "Rahti".to_string(),
136            width: 1200,
137            height: 800,
138            resizable: true,
139        }
140    }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "camelCase", deny_unknown_fields)]
145pub struct AndroidConfig {
146    /// The lowest API level the package installs on.
147    pub min_sdk: u32,
148}
149
150impl Default for AndroidConfig {
151    fn default() -> Self {
152        AndroidConfig {
153            min_sdk: MIN_ANDROID_SDK,
154        }
155    }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct BundleConfig {
161    /// Where the launcher icons live, relative to the project root.
162    pub icons: String,
163}
164
165impl Default for BundleConfig {
166    fn default() -> Self {
167        BundleConfig {
168            icons: "native/icons".to_string(),
169        }
170    }
171}
172
173/// What a packaged application does about its database.
174#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "kebab-case")]
176pub enum DatabaseMode {
177    /// A SQLite file in application storage, created on first launch.
178    ///
179    /// The only backend that can run inside the package, because it is the
180    /// only one that is a file rather than a server.
181    SqliteLocal,
182    /// The application's `DATABASE_URL` is left exactly as it is.
183    ///
184    /// What a project on PostgreSQL or MySQL gets. Its database is somewhere
185    /// else and stays there; a packaged application is a client of it, and
186    /// rewriting the connection string to a local SQLite file would start the
187    /// application against an empty database that looked like a working one.
188    Remote,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193pub struct DatabaseConfig {
194    pub mode: DatabaseMode,
195}
196
197impl Default for DatabaseConfig {
198    fn default() -> Self {
199        DatabaseConfig {
200            mode: DatabaseMode::SqliteLocal,
201        }
202    }
203}
204
205#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
206#[serde(rename_all = "camelCase", deny_unknown_fields)]
207pub struct AuthConfig {
208    /// The project's `AUTH_COOKIE_NAME`. A name, never a key — see the module
209    /// note.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub cookie_name: Option<String>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215#[serde(rename_all = "camelCase", deny_unknown_fields)]
216pub struct SecurityConfig {
217    /// Whether the embedded server refuses requests that did not come from
218    /// this launch of this application. See [`crate::gate`].
219    #[serde(default = "yes")]
220    pub loopback_token: bool,
221
222    /// The Content-Security-Policy the embedded server serves.
223    ///
224    /// The default is as narrow as PulsePoint will run under, which is one
225    /// directive wider than it looks like it should be — see [`default_csp`].
226    /// A project that adds an external script widens it here, and knows it
227    /// did.
228    #[serde(default = "default_csp")]
229    pub csp: String,
230}
231
232impl Default for SecurityConfig {
233    fn default() -> Self {
234        SecurityConfig {
235            loopback_token: true,
236            csp: default_csp(),
237        }
238    }
239}
240
241/// `default-src 'self'` and nothing external.
242///
243/// ## `'unsafe-eval'`, and why it is not optional here
244///
245/// PulsePoint compiles the expressions in a reactive block at runtime: it
246/// parses them and builds a render function with `new Function`, which the CSP
247/// counts as evaluating a string as JavaScript. That is what makes it a
248/// browser runtime rather than a build step, and it is not something a
249/// configuration option turns off.
250///
251/// Without the directive the page still renders — the document is
252/// server-rendered — and every binding on it is dead, reporting
253/// `EvalError: … violates the following Content Security Policy directive`
254/// from inside the minified bundle. Which is a failure worth naming, because
255/// the sentence "tighten the CSP" is otherwise an obvious-looking change that
256/// breaks the whole browser layer of the application in a way that reads as a
257/// PulsePoint bug.
258///
259/// It is narrower than it sounds: `script-src` still refuses every *source*
260/// but this origin, so injected markup cannot load an attacker's file. What it
261/// permits is the application's own runtime compiling the application's own
262/// expressions.
263///
264/// The rest: `connect-src` covers `pp.rpc`, the streaming responses and the
265/// WebSocket — `ws:` because the socket is on the loopback origin, which is
266/// not TLS. `img-src data:` is what an inline SVG data URI needs;
267/// `style-src 'unsafe-inline'` is what a `style` attribute needs, which
268/// PulsePoint writes when a binding targets one.
269pub fn default_csp() -> String {
270    "default-src 'self'; \
271     script-src 'self' 'unsafe-eval'; \
272     style-src 'self' 'unsafe-inline'; \
273     img-src 'self' data: blob:; \
274     font-src 'self' data:; \
275     connect-src 'self' ws: http://127.0.0.1:*; \
276     frame-ancestors 'none'; \
277     object-src 'none'; \
278     base-uri 'self'"
279        .to_string()
280}
281
282/// Policies a previous Rahti wrote as its default, and would write differently
283/// now.
284///
285/// `security.csp` is stored, so a project keeps whatever it was created with —
286/// right for a value somebody tuned, wrong for one nobody touched. Without this
287/// list a framework-level correction to the default could never reach an
288/// existing project, and the correction that prompted the list is not
289/// cosmetic: the entry below has no `'unsafe-eval'`, so every PulsePoint
290/// binding in a package built with it is dead.
291///
292/// An entry is matched byte for byte. A policy that differs by so much as a
293/// space is one somebody edited, and is left exactly as it is.
294const SUPERSEDED_CSPS: &[&str] = &[
295    // Shipped before it was found that PulsePoint compiles its reactive
296    // expressions with `new Function`.
297    "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \
298     img-src 'self' data: blob:; font-src 'self' data:; \
299     connect-src 'self' ws: http://127.0.0.1:*; frame-ancestors 'none'; \
300     object-src 'none'; base-uri 'self'",
301];
302
303/// Whether `csp` is an old default this build would now write differently.
304///
305/// Used by `cargo rahti native init` to bring an untouched policy forward, the
306/// same way an unedited scaffold file is regenerated.
307pub fn superseded_csp(csp: &str) -> bool {
308    SUPERSEDED_CSPS.contains(&csp)
309}
310
311fn yes() -> bool {
312    true
313}
314
315impl NativeConfig {
316    /// A configuration for a project that has just run `init`.
317    pub fn new(product_name: &str, identifier: &str, version: &str, targets: &[&str]) -> Self {
318        NativeConfig {
319            schema_ref: Some("./rahti.native.schema.json".to_string()),
320            schema: SCHEMA_VERSION,
321            product_name: product_name.to_string(),
322            identifier: identifier.to_string(),
323            version: version.to_string(),
324            targets: targets.iter().map(|t| t.to_string()).collect(),
325            window: WindowConfig {
326                title: product_name.to_string(),
327                ..WindowConfig::default()
328            },
329            android: AndroidConfig::default(),
330            bundle: BundleConfig::default(),
331            database: DatabaseConfig::default(),
332            auth: AuthConfig::default(),
333            local: None,
334            security: SecurityConfig::default(),
335            scaffold: BTreeMap::new(),
336        }
337    }
338
339    /// Read and validate the file at `path`.
340    pub fn load(path: &Path) -> Result<Self, NativeError> {
341        let text = std::fs::read_to_string(path).map_err(|e| {
342            if e.kind() == std::io::ErrorKind::NotFound {
343                NativeError::at(
344                    "config",
345                    path,
346                    "this project has no native configuration.\n  \
347                     Create one with:\n    \
348                     cargo rahti native init --identifier com.example.myapp --windows",
349                )
350            } else {
351                NativeError::io("config", path, e)
352            }
353        })?;
354
355        Self::parse(&text).map_err(|mut e| {
356            e.path = Some(path.to_path_buf());
357            e
358        })
359    }
360
361    /// Parse, migrate, and validate, without a file.
362    ///
363    /// Migration comes before validation on purpose. A stored value this build
364    /// would now write differently is corrected here, so every caller — `init`,
365    /// `doctor`, `build` — sees the corrected configuration, and a project
366    /// created by an older Rahti is not refused by a rule that did not exist
367    /// when its file was written. See [`superseded_csp`].
368    pub fn parse(text: &str) -> Result<Self, NativeError> {
369        let mut config: NativeConfig = serde_json::from_str(text).map_err(|e| {
370            NativeError::new("config", format!("rahti.native.json is not valid: {e}"))
371        })?;
372        config.migrate();
373        config.validate()?;
374        Ok(config)
375    }
376
377    /// Bring stored values this build owns forward.
378    ///
379    /// Only values that are byte-identical to something a previous Rahti wrote
380    /// as *its* default. Anything somebody edited is theirs and is untouched.
381    fn migrate(&mut self) {
382        if superseded_csp(&self.security.csp) {
383            self.security.csp = default_csp();
384        }
385    }
386
387    /// Serialize, formatted the way `init` writes it.
388    pub fn to_json(&self) -> String {
389        let mut text = serde_json::to_string_pretty(self).expect("a configuration serializes");
390        text.push('\n');
391        text
392    }
393
394    /// Every rule, checked in one place and before anything expensive runs.
395    pub fn validate(&self) -> Result<(), NativeError> {
396        let fail = |message: String| NativeError::new("config", message);
397
398        if self.schema != SCHEMA_VERSION {
399            return Err(fail(format!(
400                "rahti.native.json has `schema` {}, and this tool understands {SCHEMA_VERSION}.\n  \
401                 Upgrade cargo-rahti-native, or regenerate the file with `cargo rahti native init`.",
402                self.schema
403            )));
404        }
405
406        check_product_name(&self.product_name).map_err(fail)?;
407        check_identifier(&self.identifier).map_err(fail)?;
408        check_version(&self.version).map_err(fail)?;
409
410        if self.targets.is_empty() {
411            return Err(fail(
412                "`targets` is empty, so there is nothing to build.\n  \
413                 Add \"windows\", \"android\", or both."
414                    .to_string(),
415            ));
416        }
417        for target in &self.targets {
418            if !TARGETS.contains(&target.as_str()) {
419                return Err(fail(format!(
420                    "`{target}` is not a native target. Rahti packages {}.",
421                    TARGETS.join(" and ")
422                )));
423            }
424        }
425
426        if self.window.width == 0 || self.window.height == 0 {
427            return Err(fail(
428                "a window with a zero dimension has nothing to show.".to_string(),
429            ));
430        }
431
432        if self.android.min_sdk < MIN_ANDROID_SDK {
433            return Err(fail(format!(
434                "`android.minSdk` is {}, and Tauri 2 needs at least {MIN_ANDROID_SDK}.",
435                self.android.min_sdk
436            )));
437        }
438
439        if let Some(cookie) = &self.auth.cookie_name {
440            check_cookie_name(cookie).map_err(fail)?;
441        }
442
443        if self.security.csp.trim().is_empty() {
444            return Err(fail(
445                "`security.csp` is empty. A package that serves no Content-Security-Policy \
446                 puts an XSS in reach of the native command bridge — set a policy, or remove \
447                 the field to take the default."
448                    .to_string(),
449            ));
450        }
451
452        // A policy without `'unsafe-eval'` is one the application cannot run
453        // under, and the runtime symptom is bad: the server-rendered page
454        // appears, every binding on it is dead, and the only error is an
455        // `EvalError` from inside a minified bundle. Cheaper to say here.
456        let scripts = self
457            .security
458            .csp
459            .split(';')
460            .map(str::trim)
461            .find(|directive| directive.starts_with("script-src"));
462        if let Some(scripts) = scripts
463            && !scripts.contains("'unsafe-eval'")
464        {
465            return Err(fail(format!(
466                "`security.csp` has `{scripts}`, and PulsePoint cannot run under it.\n  \
467                 It compiles the expressions in a reactive block at runtime, with \
468                 `new Function` — which a Content-Security-Policy counts as evaluating a \
469                 string as JavaScript.\n  \
470                 Without `'unsafe-eval'` the page renders and every binding on it is \
471                 dead, reporting an EvalError from inside the runtime bundle.\n  \
472                 Add `'unsafe-eval'` to `script-src`. It stays narrow: `script-src` still \
473                 refuses every *source* but this origin, so injected markup cannot load \
474                 an attacker's file."
475            )));
476        }
477
478        Ok(())
479    }
480
481    /// Whether `target` is one this project asked for.
482    pub fn builds(&self, target: &str) -> bool {
483        self.targets.iter().any(|t| t == target)
484    }
485
486    /// The integer Google Play orders releases by.
487    ///
488    /// Derived rather than stored, so there is one version in the file and no
489    /// way for the two to disagree. `1.2.3` becomes `10203`, which increases
490    /// with the version for any patch or minor below 100.
491    pub fn android_version_code(&self) -> u32 {
492        let mut parts = self
493            .version
494            .split('.')
495            .map(|p| p.parse::<u32>().unwrap_or(0));
496        let major = parts.next().unwrap_or(0);
497        let minor = parts.next().unwrap_or(0);
498        let patch = parts.next().unwrap_or(0);
499        major * 10_000 + minor * 100 + patch
500    }
501}
502
503/// Reverse-DNS, and legal as an Android package name.
504///
505/// Android's rule is the strict one and is therefore the one enforced: a
506/// package name is a Java package name, so every segment is a Java identifier.
507/// A hyphen is the common mistake — `com.example.my-app` is a perfectly good
508/// Windows bundle identity and will not compile on Android, several minutes
509/// into a Gradle build that had no reason to start.
510pub fn check_identifier(identifier: &str) -> Result<(), String> {
511    let advice = "  An identifier is reverse-DNS and has to be a legal Android package name: \
512                  at least two segments, each starting with a letter and made of letters, \
513                  digits and `_`. No hyphens.\n  \
514                  For example: com.example.myapp";
515
516    if identifier.trim() != identifier || identifier.is_empty() {
517        return Err(format!(
518            "`{identifier}` is not an application identifier.\n{advice}"
519        ));
520    }
521
522    let segments: Vec<&str> = identifier.split('.').collect();
523    if segments.len() < 2 {
524        return Err(format!(
525            "`{identifier}` has one segment, and an identifier needs at least two.\n{advice}"
526        ));
527    }
528
529    for segment in &segments {
530        if segment.is_empty() {
531            return Err(format!("`{identifier}` has an empty segment.\n{advice}"));
532        }
533        if !segment.starts_with(|c: char| c.is_ascii_alphabetic()) {
534            return Err(format!(
535                "`{identifier}`: the segment `{segment}` does not start with a letter.\n{advice}"
536            ));
537        }
538        if !segment
539            .chars()
540            .all(|c| c.is_ascii_alphanumeric() || c == '_')
541        {
542            return Err(format!(
543                "`{identifier}`: the segment `{segment}` has a character that is not a letter, \
544                 a digit or `_`.\n{advice}"
545            ));
546        }
547        if JAVA_KEYWORDS.contains(segment) {
548            return Err(format!(
549                "`{identifier}`: `{segment}` is a Java keyword, which an Android package name \
550                 cannot contain.\n{advice}"
551            ));
552        }
553    }
554
555    // Tauri's own placeholder. Two applications sharing it share an
556    // installation on Android, and the second one to install replaces the
557    // first.
558    if identifier == "com.tauri.dev" {
559        return Err(
560            "`com.tauri.dev` is Tauri's placeholder identifier, and every application using it \
561             would replace every other one on the device.\n  \
562             Use your own reverse-DNS identifier, for example com.example.myapp."
563                .to_string(),
564        );
565    }
566
567    Ok(())
568}
569
570/// Segments an Android package name cannot use.
571const JAVA_KEYWORDS: &[&str] = &[
572    "abstract",
573    "assert",
574    "boolean",
575    "break",
576    "byte",
577    "case",
578    "catch",
579    "char",
580    "class",
581    "const",
582    "continue",
583    "default",
584    "do",
585    "double",
586    "else",
587    "enum",
588    "extends",
589    "final",
590    "finally",
591    "float",
592    "for",
593    "goto",
594    "if",
595    "implements",
596    "import",
597    "instanceof",
598    "int",
599    "interface",
600    "long",
601    "native",
602    "new",
603    "package",
604    "private",
605    "protected",
606    "public",
607    "return",
608    "short",
609    "static",
610    "strictfp",
611    "super",
612    "switch",
613    "synchronized",
614    "this",
615    "throw",
616    "throws",
617    "transient",
618    "try",
619    "void",
620    "volatile",
621    "while",
622];
623
624/// The installed application's name, which is also a filename.
625pub fn check_product_name(name: &str) -> Result<(), String> {
626    if name.trim().is_empty() {
627        return Err(
628            "`productName` is empty, and it is what the installed application is \
629                    called."
630                .to_string(),
631        );
632    }
633    if name.trim() != name {
634        return Err(format!(
635            "`productName` is `{name}`, which has leading or trailing whitespace. It becomes a \
636             filename, where that does not survive."
637        ));
638    }
639    // It reaches an installer path, a Start-menu entry and an APK label.
640    const FORBIDDEN: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
641    if let Some(bad) = name
642        .chars()
643        .find(|c| FORBIDDEN.contains(c) || c.is_control())
644    {
645        return Err(format!(
646            "`productName` contains `{bad}`, which cannot be in a filename — and the product \
647             name becomes one."
648        ));
649    }
650    Ok(())
651}
652
653/// `major.minor.patch`, all numeric.
654///
655/// Neither platform takes anything else. An MSI version is three numbers, and
656/// Google Play orders releases by an integer derived from these; a `-beta.1`
657/// suffix has nowhere to go in either.
658pub fn check_version(version: &str) -> Result<(), String> {
659    let parts: Vec<&str> = version.split('.').collect();
660    let numeric = parts.len() == 3
661        && parts
662            .iter()
663            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
664
665    if !numeric {
666        return Err(format!(
667            "`version` is `{version}`, and a native package needs `major.minor.patch` with all \
668             three numeric.\n  \
669             A Windows installer version is three numbers, and Google Play orders releases by an \
670             integer derived from them — a pre-release suffix has nowhere to go in either."
671        ));
672    }
673
674    for part in parts {
675        if part.parse::<u32>().is_err() {
676            return Err(format!(
677                "`version` is `{version}`, and `{part}` is too large."
678            ));
679        }
680    }
681    Ok(())
682}
683
684/// An RFC 6265 cookie name, checked here for the same reason `rahti::auth`
685/// checks it: a name carrying `;` or `=` is written and never read back, and
686/// every sign-in appears to work while nobody stays signed in.
687fn check_cookie_name(name: &str) -> Result<(), String> {
688    const SEPARATORS: &[char] = &[
689        '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ',
690    ];
691    if name.is_empty() {
692        return Err("`auth.cookieName` is empty.".to_string());
693    }
694    if name
695        .chars()
696        .any(|c| c.is_control() || SEPARATORS.contains(&c) || !c.is_ascii())
697    {
698        return Err(format!(
699            "`auth.cookieName` is `{name}`, which is not a legal cookie name.\n  \
700             It has to be a token: letters, digits, and `-_.~!#$%&'*+^|`."
701        ));
702    }
703    Ok(())
704}