//! Who may call what. Pure functions, so tests/security.rs can pin every rule. //! //! The capability file already limits the app commands to the `main` window with local content; every command checks //! again here (window label and the exact origin of the page that called), so a future second window, a stray //! navigation or a remote page can never reach them, whatever the capability files say. use serde_json::{Map, Value}; use url::Url; /// The one window that may call app commands. pub const MAIN: &str = "main"; /// The only arguments the sidecar is ever started with. pub const SIDECAR_ARGS: [&str; 2] = ["serve", "--stdio"]; /// Name of the sidecar binary (`bundle.externalBin` = binaries/storebound). pub const SIDECAR: &str = "storebound"; /// Dev server origin (`build.devUrl`); accepted only in debug builds. pub const DEV_HOST: &str = "127.0.0.1"; pub const DEV_PORT: u16 = 1420; /// The app's own bundled pages: `tauri://localhost` (macOS, Linux) or `http(s)://tauri.localhost` (Windows). /// No port, no user info, nothing that only looks similar. In debug builds also the Vite dev server. pub fn is_local_origin(url: &Url, dev: bool) -> bool { if !url.username().is_empty() || url.password().is_some() { return false; } let host = url.host_str().unwrap_or_default(); match url.scheme() { "tauri" => host == "localhost" && url.port().is_none(), "http" | "https" if host == "tauri.localhost" => url.port().is_none(), "http" if dev => host == DEV_HOST && url.port() == Some(DEV_PORT), _ => false, } } pub fn require_main(label: &str, url: &Url, dev: bool) -> Result<(), String> { if label != MAIN { return Err(format!("not allowed from window '{label}'")); } if !is_local_origin(url, dev) { return Err("not allowed from this page".into()); } Ok(()) } /// Method kinds the sidecar announces in its `session.hello` descriptors. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { Read, Write, System, Portfolio, Session, /// Account-scoped store reads (apps.list, account.check): the host runs them with one account's key. Account, /// Owner actions (rollout share, phased release, sending one approved reply, submit for review, the Play production /// release, price changes): like writes, a dry run from the /// window; the real run only through `write_with_confirm`, whose dialog says that users see it at once. Owner, } impl Kind { pub fn parse(s: &str) -> Option { Some(match s { "read" => Kind::Read, "write" => Kind::Write, "system" => Kind::System, "portfolio" => Kind::Portfolio, "session" => Kind::Session, "account" => Kind::Account, "owner" => Kind::Owner, _ => return None, }) } } /// Params keys the webview may send for a sidecar method. The project folder comes from the host's project list (the /// webview names a project id), and `confirmToken`, `credentials` and `dryRun` are the host's: tokens are minted only /// after the native dialog, credentials come from the keychain. const JS_PARAM_KEYS: &[&str] = &["args", "live"]; /// Checks a webview call to the sidecar and returns the params to send. Writes from the webview are always dry runs; /// the real run goes through `write_with_confirm` (native dialog, then a host-minted token). pub fn sanitize_js_call(method: &str, kind: Option, params: &Value) -> Result { let kind = kind.ok_or_else(|| format!("unknown method: {method}"))?; match kind { Kind::Session => return Err(format!("{method} is the host's")), Kind::Portfolio => return Err(format!("{method}: use portfolio_status")), Kind::Account => return Err(format!("{method}: use the account commands")), _ => {} } let empty = Map::new(); let obj = match params { Value::Object(o) => o, Value::Null => &empty, _ => return Err("params must be an object".into()), }; for k in obj.keys() { if !JS_PARAM_KEYS.contains(&k.as_str()) { return Err(format!("param '{k}' is not allowed from the window")); } } let mut out = obj.clone(); if matches!(kind, Kind::Write | Kind::Owner) { out.insert("dryRun".into(), Value::Bool(true)); } Ok(Value::Object(out)) } /// Owner actions that send the app to review or to production (cli/serve.ts OWNER_SUBMIT): App Store submit, resubmit /// after a rejection, in-app purchases to App Review, the Play production release. Like every owner action they run /// for real only through `write_with_confirm`; their dialog says that the app goes to review and may go live by itself. pub const SUBMIT_METHODS: &[&str] = &["owner.apple_submit", "owner.apple_resubmit", "owner.apple_iap_submit", "owner.play_production"]; /// Write methods whose project is a new folder without a config (the host picks it in a native dialog and runs them /// in its own commands: `project_init`, `project_import_fastlane`); `write_with_confirm` never runs them. pub const NEW_FOLDER_METHODS: &[&str] = &["project.init", "import.fastlane"]; /// `console.fill`: a write whose real run opens a visible browser on a store console form and lasts until the owner /// closes it. `write_with_confirm` shows its answers and this note instead of a dry-run log; a cancel (`job_cancel`) /// stops the command, which closes the browser. pub const CONSOLE_FILL: &str = "console.fill"; pub const CONSOLE_FILL_NOTE: &str = "Console pages change. This is AI-assisted: review every field in the browser and press the final button (Publish, Submit, Save or Create) yourself. storebound moves through the pages for you and never submits or publishes; App Privacy answers stay a draft until you press Publish."; /// Writes a store-only app (no folder) may run: they send only what the call carries (one language's texts), never /// the sidecar's generated config. Dry run from the window like every write; the real run only through /// `write_with_confirm`, its token bound to the store ids (`virtual`) instead of a folder. pub const STORE_ONLY_WRITES: &[&str] = &["listing.set"]; pub fn store_only_write(method: &str, kind: Option) -> bool { kind == Some(Kind::Write) && STORE_ONLY_WRITES.contains(&method) } /// Reads that are never kept in the offline cache (big: one rendered image or one live store thumbnail as a data: URL). pub const NO_CACHE_METHODS: &[&str] = &["project.render", "art.thumb"]; /// File kinds the window may export through `export_save`, with their extension. pub const EXPORT_KINDS: &[&str] = &["csv", "md", "json", "txt"]; /// Shown before an export with personal data (reviews: reviewer names, texts, territories) is saved. pub const PERSONAL_EXPORT_NOTE: &str = "This file contains personal data: reviewer names, review texts and where they wrote from.\n\nKeep it on this computer (for example in the app's .storebound folder, which git ignores). Do not put it into issues, docs, chats or commits."; /// Largest export the window may hand over (bytes of text). pub const EXPORT_MAX_BYTES: usize = 16 * 1024 * 1024; /// The suggested file name of an export: letters, digits, space, dot, dash, underscore and parentheses only, at most /// 120 characters, never hidden, always ending in the kind's extension. None for an unknown kind or an empty name. pub fn export_file_name(name: &str, kind: &str) -> Option { if !EXPORT_KINDS.contains(&kind) { return None; } let clean: String = name .chars() .map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '.' | '-' | '_' | '(' | ')') { c } else { '-' }) .take(120) .collect(); let clean = clean.trim().trim_start_matches('.').trim().to_string(); let ext = format!(".{kind}"); let stem = clean.strip_suffix(&ext).unwrap_or(&clean).trim_end_matches('.').to_string(); if stem.is_empty() { return None; } Some(format!("{stem}{ext}")) } /// A path inside an app folder named by the window (the renders folder, the gallery page): relative, plain /// components only (no `..`, no root, no drive), at most 400 bytes. None otherwise. pub fn project_relative(rel: &str) -> Option { if rel.is_empty() || rel.len() > 400 || rel.chars().any(char::is_control) || rel.contains('\\') { return None; } let p = std::path::Path::new(rel); if p.is_absolute() || !p.components().all(|c| matches!(c, std::path::Component::Normal(_))) { return None; } Some(p.to_path_buf()) } /// What `project_reveal` may open (not only show in the file manager): a folder, the gallery page storebound writes, /// or a readiness report page of `storebound report` (.storebound/reports/readiness-*.html, no scripts in it). pub fn openable(rel: &std::path::Path, is_dir: bool) -> bool { if is_dir || rel.file_name().and_then(|n| n.to_str()) == Some("index.html") { return true; } let parts: Vec<&str> = rel.components().filter_map(|c| c.as_os_str().to_str()).collect(); matches!(parts.as_slice(), [".storebound", "reports", name] if name.starts_with("readiness-") && name.ends_with(".html") && name.len() <= 80) } /// Hosts `open_external` may open in the browser: the two store consoles and storebound's own site. pub const EXTERNAL_HOSTS: &[&str] = &[ "play.google.com", "appstoreconnect.apple.com", "developer.apple.com", "storebound.dev", // Dependencies screen link-outs "developer.android.com", "ffmpeg.org", "googlechromelabs.github.io", "git-scm.com", // account health fixes (enable an API in the service account's Google Cloud project) "console.cloud.google.com", ]; /// Executable of a Chrome for Testing download, relative to its unpacked folder (app data `tools/chrome/`). pub fn chrome_executable() -> &'static str { if cfg!(target_os = "macos") { "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" } else if cfg!(windows) { "chrome.exe" } else { "chrome" } } pub fn external_allowed(url: &Url) -> bool { url.scheme() == "https" && url.username().is_empty() && url.password().is_none() && url.port().is_none() && url.host_str().is_some_and(|h| EXTERNAL_HOSTS.contains(&h)) } /// Deep links the app reacts to: `storebound://` for a known screen, nothing else. pub const DEEP_LINK_SCREENS: &[&str] = &["portfolio", "apps", "accounts", "ignored", "dependencies", "settings"]; pub fn deep_link_screen(url: &Url) -> Option<&'static str> { if url.scheme() != "storebound" { return None; } let host = url.host_str().unwrap_or_default(); DEEP_LINK_SCREENS.iter().copied().find(|s| *s == host) } /// `storebound://open?path=` (what `storebound desktop` sends from a terminal): the folder to add or /// select. Only an absolute, plain path: no `..`, no NUL or control characters, at most 4096 bytes, one `path` /// parameter and nothing else. The host still checks that it is a folder with a storebound config and asks the owner /// in a native dialog before adding it. pub fn deep_link_open_path(url: &Url) -> Option { if url.scheme() != "storebound" || url.host_str() != Some("open") || !url.username().is_empty() || url.password().is_some() || url.port().is_some() { return None; } if !(url.path().is_empty() || url.path() == "/") || url.fragment().is_some() { return None; } let pairs: Vec<(String, String)> = url.query_pairs().map(|(k, v)| (k.into_owned(), v.into_owned())).collect(); let [(k, v)] = pairs.as_slice() else { return None }; if k != "path" || v.is_empty() || v.len() > 4096 || v.chars().any(char::is_control) { return None; } let p = std::path::PathBuf::from(v); if !p.is_absolute() || p.components().any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir)) { return None; } Some(p) }