Extract the wayBetter framework core from wayBetter for X
Site-agnostic parts of the X app, behind a small site adapter: - crates/waybetter-host: webview-label security policy and tab rect clamp, account profiles with native-owned accounts (AccountRules), plugin store with bundled-plugin upgrades (PluginHost), signed data-only hotfix channel, update scheduling and crash handshake, tab payloads/link routing/tab-chrome validation, and (feature "tauri") webview helpers and the Windows system menu. - packages/runtime: DOM hub, request hub, plugin API core, OKLab theme color model, split-view rail hiding, link routing and account reporting, each configured by a SiteAdapter. - packages/shell: address bar, pane, link-drop, profile, palette and update-chip models, and window controls with the Tauri API injected. Tests moved/adapted from the app: 45 Rust (headless), 40 TypeScript.
This commit is contained in:
commit
b2a91ff7b3
48 changed files with 9768 additions and 0 deletions
167
crates/waybetter-host/src/hotfix.rs
Normal file
167
crates/waybetter-host/src/hotfix.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
//! Signed, data-only hotfix channel: selector and flag overrides for site markup changes.
|
||||
//!
|
||||
//! The body is JSON (`{version, expires, minNative, selectors{}, flags{}}`) with a detached
|
||||
//! minisign signature from a separate hotfix key. It is verified, schema-checked, required to be
|
||||
//! newer than the copy already held (monotonic) and unexpired, and then handed to site tabs as
|
||||
//! frozen data (`window.<global>`). It is never evaluated as code: a leaked hotfix key can at most
|
||||
//! break selectors. The app owns the public key and channel URL.
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::{DateTime, Utc};
|
||||
use minisign_verify::{PublicKey, Signature};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const MAX_BODY_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_SIG_BYTES: usize = 4 * 1024;
|
||||
const MAX_SELECTORS: usize = 256;
|
||||
const MAX_FLAGS: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Hotfix {
|
||||
/// Monotonic; a client never accepts a version at or below the one it holds.
|
||||
pub version: u64,
|
||||
/// RFC 3339 timestamp after which the hotfix is ignored (blocks stale replay).
|
||||
pub expires: String,
|
||||
/// Lowest native app version this data is written for.
|
||||
pub min_native: String,
|
||||
#[serde(default)]
|
||||
pub selectors: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub flags: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Accepts either the raw minisign text or the base64 wrapping that the Tauri signer produces.
|
||||
pub fn minisign_text(value: &str) -> Result<String, String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.starts_with("untrusted comment:") {
|
||||
return Ok(trimmed.to_owned());
|
||||
}
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(trimmed)
|
||||
.map_err(|_| "HOTFIX: key or signature is not minisign text or base64")?;
|
||||
String::from_utf8(bytes).map_err(|_| "HOTFIX: key or signature is not UTF-8".into())
|
||||
}
|
||||
|
||||
/// Verifies `body` against a detached minisign signature.
|
||||
pub fn verify(pubkey: &str, body: &[u8], signature: &str) -> Result<(), String> {
|
||||
if signature.len() > MAX_SIG_BYTES {
|
||||
return Err("HOTFIX: signature too large".into());
|
||||
}
|
||||
let key_text = minisign_text(pubkey)?;
|
||||
let key_line = key_text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty() && !line.starts_with("untrusted comment:"))
|
||||
.ok_or("HOTFIX: empty public key")?;
|
||||
let key = PublicKey::from_base64(key_line).map_err(|_| "HOTFIX: invalid public key")?;
|
||||
let signature = Signature::decode(&minisign_text(signature)?).map_err(|_| "HOTFIX: invalid signature")?;
|
||||
key.verify(body, &signature, false)
|
||||
.map_err(|_| "HOTFIX: signature verification failed".into())
|
||||
}
|
||||
|
||||
fn semver_triplet(value: &str) -> Option<(u64, u64, u64, bool)> {
|
||||
let (core, pre) = match value.split_once('-') {
|
||||
Some((core, pre)) => (core, !pre.is_empty()),
|
||||
None => (value, false),
|
||||
};
|
||||
let mut parts = core.split('.').map(|p| p.parse::<u64>().ok());
|
||||
let triplet = (parts.next()??, parts.next()??, parts.next()??, pre);
|
||||
parts.next().is_none().then_some(triplet)
|
||||
}
|
||||
|
||||
/// `a >= b` for semantic versions (a prerelease sorts before its release).
|
||||
pub fn version_at_least(a: &str, b: &str) -> Option<bool> {
|
||||
let (a, b) = (semver_triplet(a)?, semver_triplet(b)?);
|
||||
Some(match (a.0, a.1, a.2).cmp(&(b.0, b.1, b.2)) {
|
||||
std::cmp::Ordering::Greater => true,
|
||||
std::cmp::Ordering::Less => false,
|
||||
std::cmp::Ordering::Equal => !a.3 || b.3,
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_key(key: &str) -> bool {
|
||||
!key.is_empty() && key.len() <= 64 && key.bytes().all(|c| c.is_ascii_alphanumeric() || b"_-.".contains(&c))
|
||||
}
|
||||
|
||||
/// Schema, expiry and native-version checks on an already verified body.
|
||||
pub fn validate(body: &[u8], now: DateTime<Utc>, native_version: &str) -> Result<Hotfix, String> {
|
||||
if body.len() > MAX_BODY_BYTES {
|
||||
return Err("HOTFIX: body too large".into());
|
||||
}
|
||||
let hotfix: Hotfix = serde_json::from_slice(body).map_err(|error| format!("HOTFIX: schema: {error}"))?;
|
||||
let expires = DateTime::parse_from_rfc3339(&hotfix.expires).map_err(|_| "HOTFIX: invalid expires")?;
|
||||
if expires.with_timezone(&Utc) <= now {
|
||||
return Err("HOTFIX: expired".into());
|
||||
}
|
||||
match version_at_least(native_version, &hotfix.min_native) {
|
||||
Some(true) => {}
|
||||
Some(false) => return Err("HOTFIX: requires a newer app".into()),
|
||||
None => return Err("HOTFIX: invalid minNative".into()),
|
||||
}
|
||||
if hotfix.selectors.len() > MAX_SELECTORS
|
||||
|| hotfix.selectors.iter().any(|(k, v)| !valid_key(k) || v.is_empty() || v.len() > 1024)
|
||||
{
|
||||
return Err("HOTFIX: invalid selectors".into());
|
||||
}
|
||||
let scalar = |v: &serde_json::Value| match v {
|
||||
serde_json::Value::Bool(_) | serde_json::Value::Number(_) => true,
|
||||
serde_json::Value::String(s) => s.len() <= 256,
|
||||
_ => false,
|
||||
};
|
||||
if hotfix.flags.len() > MAX_FLAGS || hotfix.flags.iter().any(|(k, v)| !valid_key(k) || !scalar(v)) {
|
||||
return Err("HOTFIX: invalid flags".into());
|
||||
}
|
||||
Ok(hotfix)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Outcome {
|
||||
/// A newer valid hotfix to store and deliver.
|
||||
Accepted(Hotfix),
|
||||
/// Same version as the one already held; nothing to do.
|
||||
Unchanged,
|
||||
}
|
||||
|
||||
/// Full client-side acceptance: signature first, then schema/expiry/minNative, then monotonicity.
|
||||
pub fn accept(
|
||||
pubkey: &str,
|
||||
body: &[u8],
|
||||
signature: &str,
|
||||
now: DateTime<Utc>,
|
||||
native_version: &str,
|
||||
held_version: Option<u64>,
|
||||
) -> Result<Outcome, String> {
|
||||
verify(pubkey, body, signature)?;
|
||||
let hotfix = validate(body, now, native_version)?;
|
||||
match held_version {
|
||||
Some(held) if hotfix.version == held => Ok(Outcome::Unchanged),
|
||||
Some(held) if hotfix.version < held => Err("HOTFIX: older than the held version".into()),
|
||||
_ => Ok(Outcome::Accepted(hotfix)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivers the hotfix to a site page as data: parsed from a JSON string literal, frozen, stored
|
||||
/// as `window.<global>` and announced with the `event` window event.
|
||||
pub fn script(hotfix: &Hotfix, global: &str, event: &str) -> String {
|
||||
let json = serde_json::to_string(hotfix).unwrap_or_else(|_| "null".into());
|
||||
let literal = serde_json::to_string(&json).unwrap_or_else(|_| "\"null\"".into());
|
||||
format!(
|
||||
"(()=>{{const h=JSON.parse({literal});if(h){{Object.freeze(h.selectors);Object.freeze(h.flags);Object.freeze(h);}}window.{global}=h;try{{window.dispatchEvent(new CustomEvent('{event}'));}}catch(_){{}}}})();"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn semver_ordering() {
|
||||
assert_eq!(version_at_least("0.2.0", "0.2.0"), Some(true));
|
||||
assert_eq!(version_at_least("0.10.0", "0.9.9"), Some(true));
|
||||
assert_eq!(version_at_least("0.2.0-beta.1", "0.2.0"), Some(false));
|
||||
assert_eq!(version_at_least("0.2.0", "0.2.0-beta.1"), Some(true));
|
||||
assert_eq!(version_at_least("0.2", "0.2.0"), None);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue