Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4394,18 +4394,18 @@ public func FfiConverterTypeNativeRuntimeConfigError_lower(_ value: NativeRuntim
* a raw string so the dispatcher can reject invalid input before reaching
* any platform callback. The open variants carry the ready-to-load canonical
* URL; `DotName` and `Localhost` keep the dotns/localhost identity visible so
* env-aware hosts can rewrite `.dot` names for their active environment and
* env-aware hosts can rewrite dotNS names for their active environment and
* re-parse without losing information.
*/

public enum NavigateDecision: Equatable, Hashable {

/**
* A `.dot` identifier plus path/query/hash suffix (no leading `/`).
* A dotNS identifier plus path/query/hash suffix (no leading `/`).
*/
case dotName(
/**
* Lower-cased `.dot` host (e.g. `mytestapp.dot`).
* Lower-cased dotNS host (e.g. `mytestapp.dot`).
*/identifier: String,
/**
* Path/query/hash suffix without a leading `/`.
Expand Down Expand Up @@ -4437,7 +4437,7 @@ public enum NavigateDecision: Equatable, Hashable {
*/url: String
)
/**
* Input that fails every branch: empty, unparseable, or a `.dot` URL
* Input that fails every branch: empty, unparseable, or a dotNS URL
* carrying port/userinfo (both forbidden since dotns resolves via the
* chain and has no notion of either).
*/
Expand Down Expand Up @@ -5228,7 +5228,7 @@ public func uniffiForeignFutureHandleCountTruapiServer() -> Int {
}
/**
* Classify a navigation input exactly like the core's internal navigate host
* call: `.dot` first, then `localhost`, then normalized external, with
* call: dotNS first, then `localhost`, then normalized external, with
* everything else rejected. Pure and stateless; hosts call it on every
* webview-internal navigation.
*/
Expand Down Expand Up @@ -5269,7 +5269,7 @@ private let initializationResult: InitializationResult = {
if bindings_contract_version != scaffolding_contract_version {
return InitializationResult.contractVersionMismatch
}
if (uniffi_truapi_server_checksum_func_parse_navigate() != 58140) {
if (uniffi_truapi_server_checksum_func_parse_navigate() != 62582) {
return InitializationResult.apiChecksumMismatch
}
if (uniffi_truapi_server_checksum_func_set_log_level() != 13010) {
Expand Down
3 changes: 2 additions & 1 deletion rust/crates/truapi-host-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ res.match(
);
```

`--product-id` (a `.dot` name or `localhost` identifier; default
`--product-id` (a dotNS name ending in `.dot` or `.paseo`, or a `localhost`
identifier; default
`headless-playground.dot`) sets the initial product. `/product <id>` changes it
for the lifetime of the process. Switching disconnects active product
WebSockets so clients reconnect with a new product context; the network,
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/truapi-host-cli/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ use `exec '/script <path>'` instead. `/copy` is unavailable. `/clear` and

Accepted product identifiers are:

- a name ending in `.dot`;
- a name ending in a dotNS TLD (`.dot` or `.paseo`);
- `localhost`; or
- a string beginning with `localhost:`.

Expand Down
3 changes: 2 additions & 1 deletion rust/crates/truapi-host-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ use crate::terminal_ui::{
};

/// Default product served by the pairing host's frame endpoint. Product ids
/// must be a `.dot` name or a `localhost` identifier (host-spec product id).
/// must be a dotNS name (`.dot` or `.paseo`) or a `localhost` identifier
/// (host-spec product id).
const DEFAULT_PRODUCT_ID: &str = "headless-playground.dot";
/// Deeplink scheme advertised by the pairing host.
const DEEPLINK_SCHEME: &str = "polkadotapp";
Expand Down
17 changes: 14 additions & 3 deletions rust/crates/truapi-platform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,25 @@ pub fn is_product_identifier(identifier: &str) -> bool {
normalize_product_identifier(identifier).is_ok()
}

/// Top-level domains that dotNS deployments register product names under.
pub const DOTNS_TLDS: &[&str] = &["dot", "paseo"];

/// Whether `normalized` ends in one of [`DOTNS_TLDS`]. Expects an
/// already-lowercased host with no trailing root dot.
pub fn has_dotns_tld(normalized: &str) -> bool {
normalized
.rsplit_once('.')
Comment thread
BigTava marked this conversation as resolved.
.is_some_and(|(_, tld)| DOTNS_TLDS.contains(&tld))
}

/// Normalize product identifiers before derivation and policy checks.
pub fn normalize_product_identifier(
product_id: &str,
) -> Result<String, RuntimeConfigValidationError> {
let trimmed = product_id.trim();
require_non_empty("product_id", trimmed)?;
let normalized = trimmed.nfc().collect::<String>().to_lowercase();
if normalized.ends_with(".dot")
if has_dotns_tld(&normalized)
|| normalized == "localhost"
|| normalized.starts_with("localhost:")
{
Expand Down Expand Up @@ -279,8 +290,8 @@ pub enum RuntimeConfigValidationError {
/// Actual deeplink scheme value.
scheme: String,
},
/// Product id was not a `.dot` or localhost product identifier.
#[display("product_id must be a .dot or localhost product identifier, got {product_id:?}")]
/// Product id was not a dotNS or localhost product identifier.
#[display("product_id must be a dotNS or localhost product identifier, got {product_id:?}")]
InvalidProductId {
/// Actual product id value.
product_id: String,
Expand Down
16 changes: 12 additions & 4 deletions rust/crates/truapi-platform/tests/bounds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,19 @@ fn product_context_validation_cases() {
Ok(())
);
assert_eq!(
ProductContext::new("example.com".to_string()).map(|_| ()),
Err(RuntimeConfigValidationError::InvalidProductId {
product_id: "example.com".to_string(),
})
ProductContext::new("Host-Playground44.PASEO".to_string())
.map(|context| context.product_id),
Ok("host-playground44.paseo".to_string())
);
for domain in ["example.com", "example.org", "dotli.dotty"] {
assert_eq!(
ProductContext::new(domain.to_string()).map(|_| ()),
Err(RuntimeConfigValidationError::InvalidProductId {
product_id: domain.to_string(),
}),
"{domain} must not be accepted as a product identifier"
);
}
assert_eq!(
ProductContext::new(" ".to_string()).map(|_| ()),
Err(RuntimeConfigValidationError::EmptyField {
Expand Down
63 changes: 48 additions & 15 deletions rust/crates/truapi-server/src/host_logic/dotns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@
//! same categorization and the `navigate_to` callback only receives
//! already-validated input.

use truapi_platform::has_dotns_tld;
use unicode_normalization::UnicodeNormalization;
use url::Url;

/// How the input URL should be opened. Kept in one enum rather than passing
/// a raw string so the dispatcher can reject invalid input before reaching
/// any platform callback. The open variants carry the ready-to-load canonical
/// URL; `DotName` and `Localhost` keep the dotns/localhost identity visible so
/// env-aware hosts can rewrite `.dot` names for their active environment and
/// env-aware hosts can rewrite dotNS names for their active environment and
/// re-parse without losing information.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))]
pub enum NavigateDecision {
/// A `.dot` identifier plus path/query/hash suffix (no leading `/`).
/// A dotNS identifier plus path/query/hash suffix (no leading `/`).
DotName {
/// Lower-cased `.dot` host (e.g. `mytestapp.dot`).
/// Lower-cased dotNS host (e.g. `mytestapp.dot`).
identifier: String,
/// Path/query/hash suffix without a leading `/`.
path: String,
Expand All @@ -39,7 +40,7 @@ pub enum NavigateDecision {
/// Canonical URL string.
url: String,
},
/// Input that fails every branch: empty, unparseable, or a `.dot` URL
/// Input that fails every branch: empty, unparseable, or a dotNS URL
/// carrying port/userinfo (both forbidden since dotns resolves via the
/// chain and has no notion of either).
Reject {
Expand All @@ -56,7 +57,7 @@ fn join_url(scheme: &str, host: &str, path: &str) -> String {
}
}

/// Classify a URL the way the host navigation handler does: try `.dot` first,
/// Classify a URL the way the host navigation handler does: try dotNS first,
/// then `localhost`, then normalize as external.
pub fn parse_navigate(input: &str) -> NavigateDecision {
let trimmed = input.trim();
Expand All @@ -66,7 +67,7 @@ pub fn parse_navigate(input: &str) -> NavigateDecision {
};
}

if let Some(decision) = classify_dot(trimmed) {
if let Some(decision) = classify_dotns(trimmed) {
return decision;
}

Expand All @@ -92,10 +93,12 @@ fn normalize_host(host: &str) -> String {
.to_string()
}

/// `.dot` TLD check, applied to the [`normalize_host`] form so `Example.DOT`
/// dotNS TLD check, applied to the [`normalize_host`] form so `Example.DOT`
/// and the trailing-dot FQDN `example.dot.` classify like `example.dot`.
fn is_dot_domain(host: &str) -> bool {
normalize_host(host).ends_with(".dot")
/// Shares [`truapi_platform::DOTNS_TLDS`] with product-identifier validation
/// so navigation and derivation accept the same per-network names.
fn is_dotns_domain(host: &str) -> bool {
has_dotns_tld(&normalize_host(host))
}

fn parse_with_explicit_https(input: &str) -> Option<Url> {
Expand All @@ -105,20 +108,20 @@ fn parse_with_explicit_https(input: &str) -> Option<Url> {
Url::parse(&format!("https://{input}")).ok()
}

/// Recognize `.dot` URLs (including the `polkadot://` scheme). Returns:
/// - `Some(DotName)` for a clean `.dot` URL
/// - `Some(Reject)` for a `.dot` URL with port or userinfo
/// - `None` when the input isn't a `.dot` URL (caller falls through to
/// Recognize dotNS URLs (including the `polkadot://` scheme). Returns:
/// - `Some(DotName)` for a clean dotNS URL
/// - `Some(Reject)` for a dotNS URL with port or userinfo
/// - `None` when the input isn't a dotNS URL (caller falls through to
/// localhost / external)
fn classify_dot(input: &str) -> Option<NavigateDecision> {
fn classify_dotns(input: &str) -> Option<NavigateDecision> {
let parsed = if input.starts_with("polkadot://") {
Url::parse(input).ok()?
} else {
parse_with_explicit_https(input)?
};

let hostname = parsed.host_str()?;
if !is_dot_domain(hostname) {
if !is_dotns_domain(hostname) {
return None;
}

Expand Down Expand Up @@ -334,6 +337,36 @@ mod tests {
input: "https://user:pass@x.dot/path",
expected: Expected::Reject,
},
TestCase {
name: "paseo bare",
input: "mytestapp.paseo",
expected: dot("mytestapp.paseo", ""),
},
TestCase {
name: "paseo with path query hash",
input: "pr508.faucet.paseo/nested/path?embed=1#frame=compact",
expected: dot("pr508.faucet.paseo", "nested/path?embed=1#frame=compact"),
},
TestCase {
name: "paseo mixed case",
input: "Example.PASEO/Path",
expected: dot("example.paseo", "Path"),
},
TestCase {
name: "polkadot scheme paseo host",
input: "polkadot://currenthost.paseo/mytestapp.paseo",
expected: dot("currenthost.paseo", "mytestapp.paseo"),
},
TestCase {
name: "paseo with port is rejected",
input: "https://x.paseo:8443/path",
expected: Expected::Reject,
},
TestCase {
name: "paseo with userinfo is rejected",
input: "https://user:pass@x.paseo/path",
expected: Expected::Reject,
},
TestCase {
name: "trim whitespace",
input: " mytestapp.dot/path ",
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/truapi-server/src/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ impl From<HostNavigateRejection> for v01::HostNavigateToError {
}

/// Classify a navigation input exactly like the core's internal navigate host
/// call: `.dot` first, then `localhost`, then normalized external, with
/// call: dotNS first, then `localhost`, then normalized external, with
/// everything else rejected. Pure and stateless; hosts call it on every
/// webview-internal navigation.
#[uniffi::export]
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/truapi-server/src/wasm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue
),
RuntimeConfigValidationError::InvalidProductId { product_id } => {
JsValue::from_str(&format!(
"runtimeConfig.productId must be a .dot or localhost product identifier, got {product_id:?}"
"runtimeConfig.productId must be a dotNS or localhost product identifier, got {product_id:?}"
))
}
}
Expand Down