From 1ed7f1fdfc3e23560e080027300970aabfb82e5a Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 23 Jul 2026 17:00:05 +0900 Subject: [PATCH 1/2] Add immutable web export --- .gitignore | 1 + Cargo.lock | 1 + README.md | 10 + crates/uhura-cli/Cargo.toml | 1 + crates/uhura-cli/src/cmd/export.rs | 1188 ++++++++++++++++++++++++ crates/uhura-cli/src/cmd/mod.rs | 1 + crates/uhura-cli/src/cmd/play.rs | 52 +- crates/uhura-cli/src/main.rs | 56 +- crates/uhura-cli/tests/export.rs | 375 ++++++++ crates/uhura-host/src/lib.rs | 397 +++++++- crates/uhura-port/src/route.rs | 34 +- docs/README.md | 3 + docs/rfcs/0006-immutable-web-export.md | 319 +++++++ docs/rfcs/README.md | 1 + scripts/package.sh | 6 + web/README.md | 12 +- web/package.json | 3 +- web/src/app/host.test.ts | 202 ++++ web/src/app/host.ts | 488 ++++++++++ web/src/app/index.html | 3 + web/src/app/router.test.ts | 98 +- web/src/app/router.ts | 53 +- web/src/editor/editor.ts | 81 +- web/src/play/application-location.ts | 25 +- web/src/play/browser-adapters.test.ts | 12 + web/src/play/browser-adapters.ts | 4 +- web/src/play/main.ts | 75 +- web/src/play/shell.ts | 4 +- web/src/renderer/assets.ts | 6 +- web/vite.config.ts | 55 +- 30 files changed, 3463 insertions(+), 103 deletions(-) create mode 100644 crates/uhura-cli/src/cmd/export.rs create mode 100644 crates/uhura-cli/tests/export.rs create mode 100644 docs/rfcs/0006-immutable-web-export.md create mode 100644 web/src/app/host.test.ts create mode 100644 web/src/app/host.ts diff --git a/.gitignore b/.gitignore index de80a41..05cb95e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /crates/uhura-wasm/pkg /web/node_modules /web/dist +/web/dist-export /examples/instagram/client/providers/dist /examples/instagram/client/build /examples/applications/*/answers/*/build diff --git a/Cargo.lock b/Cargo.lock index 3a1787d..b22f66e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,6 +540,7 @@ dependencies = [ "uhura-check", "uhura-core", "uhura-host", + "uhura-port", "uhura-project", "uhura-syntax", ] diff --git a/README.md b/README.md index e1c9321..cdc3ffd 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ to authoritative data and operations. - Typed ports admitted against exact adapter ownership and contract identities. - A read-only Editor for browsing checked previews. - A Play mode for running the experience against a provider. +- An immutable Web export of one checked Editor/Play generation. - One canonical engine used natively and through Wasm, with cross-boundary conformance tests. @@ -79,6 +80,10 @@ cargo run --locked -p uhura-cli -- check \ # Start Play as the primary route; Editor remains available at / cargo run --locked -p uhura-cli -- play examples/instagram/client +# Export ordinary files for any static host; no Uhura process runs at serving time +cargo run --locked -p uhura-cli -- export examples/instagram/client \ + --out dist/instagram-web --mount /instagram/ + # Serialize one source-authored evidence scenario as canonical JSONL cargo run --locked -p uhura-cli -- trace examples/instagram/client \ --script=feed_like_refused_scenario --expanded @@ -94,6 +99,11 @@ corepack pnpm@10.11.0 -C web check Editor previews. `--script` selects an authored `scenario`; it is not a fixture-script language or an alternate runtime. +`export` uses the packaged mount-neutral Web template and browser Wasm runtime. +The resulting directory is host-vendor agnostic but mount-specific. Its +`uhura-static-bundle.json` records the entry document and required +mount-scoped history fallback. + ## Repository layout - [`crates/`](crates/) — checker, runtime, host, Wasm bindings, CLI, and the diff --git a/crates/uhura-cli/Cargo.toml b/crates/uhura-cli/Cargo.toml index ee8277f..3cef006 100644 --- a/crates/uhura-cli/Cargo.toml +++ b/crates/uhura-cli/Cargo.toml @@ -26,6 +26,7 @@ uhura-check = { workspace = true } uhura-core = { workspace = true } uhura-project = { workspace = true } uhura-host = { workspace = true } +uhura-port = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tiny_http = { workspace = true } diff --git a/crates/uhura-cli/src/cmd/export.rs b/crates/uhura-cli/src/cmd/export.rs new file mode 100644 index 0000000..1a34ce1 --- /dev/null +++ b/crates/uhura-cli/src/cmd/export.rs @@ -0,0 +1,1188 @@ +//! `uhura export [path] --out ` — materialize one immutable, +//! listenerless Editor/Play web bundle from a checked project. + +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::ExitCode; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use uhura_base::sha256_hex; +use uhura_host::{Host, StaticWebFile}; + +use crate::CommonArgs; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct StaticBundleManifest { + protocol: &'static str, + bundle_id: String, + source_id: String, + tool_version: &'static str, + mount_path: String, + play_entry: String, + entry_document: &'static str, + history_fallback: HistoryFallback, + editor_revision: u64, + play_generation: u64, + previews: usize, + replay_derived_previews: usize, + files: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct HistoryFallback { + scope: String, + file: &'static str, + methods: [&'static str; 2], + only_when_file_missing: bool, + exclude_prefixes: [&'static str; 2], +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct StaticBundleFile { + path: String, + sha256: String, + bytes: usize, + content_type: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WebBuildMarker { + protocol: String, + profile: String, + asset_base: String, + host_config_protocol: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MaterializedWebBuildMarker<'a> { + protocol: &'static str, + profile: &'static str, + asset_base: &'a str, + host_config_protocol: &'static str, + mount_path: &'a str, + play_entry: &'a str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BrowserHostConfig<'a> { + protocol: &'static str, + mount_path: &'a str, + mode: &'static str, + play_entry: &'a str, +} + +const WEB_BUILD_PROTOCOL: &str = "uhura-web-build/1"; +const HOST_CONFIG_PROTOCOL: &str = "uhura-host-config/0"; +const HOST_CONFIG_OPEN: &str = "") + .map(|offset| config_start + offset) + .ok_or_else(|| "export Web template has an unterminated #uhura-host-config".to_string())?; + let config = serde_json::to_string(&BrowserHostConfig { + protocol: HOST_CONFIG_PROTOCOL, + mount_path, + mode: "static", + play_entry: logical_play_entry, + }) + .map_err(|error| format!("could not encode browser host config: {error}"))?; + let config = escape_html_script_json(&config); + let mut configured = String::with_capacity(materialized.len() + config.len()); + configured.push_str(&materialized[..config_start]); + configured.push_str(&config); + configured.push_str(&materialized[config_end..]); + index.bytes = configured.into_bytes(); + + let marker = files + .iter_mut() + .find(|file| file.path == "uhura-web-build.json") + .expect("validated export Web marker exists"); + marker.bytes = serde_json::to_vec_pretty(&MaterializedWebBuildMarker { + protocol: WEB_BUILD_PROTOCOL, + profile: "static-export", + asset_base: mount_path, + host_config_protocol: HOST_CONFIG_PROTOCOL, + mount_path, + play_entry: public_play_entry, + }) + .map_err(|error| format!("could not encode materialized Web marker: {error}"))?; + marker.bytes.push(b'\n'); + Ok(()) +} + +fn validate_materialized_web( + files: &[StaticWebFile], + mount_path: &str, + public_play_entry: &str, +) -> Result<(), String> { + let index = files + .iter() + .find(|file| file.path == "index.html") + .ok_or_else(|| "static web build is missing index.html".to_string())?; + let index = std::str::from_utf8(&index.bytes) + .map_err(|error| format!("static web index.html is not UTF-8: {error}"))?; + if !index.contains(&format!("{}assets/", escape_html_attribute(mount_path))) { + return Err(format!( + "static web index.html does not use declared mount path `{mount_path}`" + )); + } + if index.contains("./assets/") { + return Err("static web index.html still contains relative entry assets".to_string()); + } + let expected_config = escape_html_script_json( + &serde_json::to_string(&BrowserHostConfig { + protocol: HOST_CONFIG_PROTOCOL, + mount_path, + mode: "static", + play_entry: strip_mount_from_url(mount_path, public_play_entry) + .expect("public Play entry was mounted from the same path"), + }) + .expect("browser host config is serializable"), + ); + if !index.contains(&format!("{HOST_CONFIG_OPEN}{expected_config}")) { + return Err("static web index.html has inconsistent host configuration".to_string()); + } + Ok(()) +} + +fn escape_html_script_json(json: &str) -> String { + json.replace('&', "\\u0026") + .replace('<', "\\u003c") + .replace('>', "\\u003e") + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029") +} + +fn escape_html_attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('\'', "'") + .replace('<', "<") + .replace('>', ">") +} + +fn strip_mount_from_url<'a>(mount_path: &str, url: &'a str) -> Option<&'a str> { + if mount_path == "/" { + return Some(url); + } + url.strip_prefix(mount_path.trim_end_matches('/')) +} + +fn mounted_url(mount_path: &str, logical_url: &str) -> String { + if mount_path == "/" { + logical_url.to_string() + } else { + format!("{}{}", mount_path.trim_end_matches('/'), logical_url) + } +} + +fn normalize_mount_path(value: &str) -> Result { + if value.is_empty() || value != value.trim() { + return Err("mount path must be an origin-local path".to_string()); + } + let candidate = if value == "/" || value.ends_with('/') { + value.to_string() + } else { + format!("{value}/") + }; + normalize_origin_path(&candidate, "mount path", true, false) +} + +fn normalize_play_entry(value: &str) -> Result { + if value.is_empty() || value != value.trim() { + return Err("Play entry must be an origin-local path".to_string()); + } + let boundary = value + .char_indices() + .find_map(|(index, character)| matches!(character, '?' | '#').then_some(index)) + .unwrap_or(value.len()); + let pathname = &value[..boundary]; + let pathname = normalize_play_entry_path(pathname, "Play entry")?; + if play_entry_path_is_reserved(&pathname) { + return Err("Play entry must select the Play surface".to_string()); + } + let suffix = normalize_play_entry_suffix(&value[boundary..], "Play entry")?; + Ok(format!("{pathname}{suffix}")) +} + +fn normalize_play_entry_path(value: &str, label: &str) -> Result { + if !value.starts_with('/') || value.starts_with("//") || value.contains(['\\', '?', '#']) { + return Err(format!("{label} must be an origin-local path")); + } + if value == "/" { + return Ok("/".to_string()); + } + if value.ends_with('/') && value != "/play/" { + return Err(format!("{label} contains an empty path segment")); + } + let body = value + .strip_prefix('/') + .expect("origin-local path has a leading slash"); + let body = body.strip_suffix('/').unwrap_or(body); + let mut normalized = Vec::new(); + for segment in body.split('/') { + if segment.is_empty() { + return Err(format!("{label} contains an empty path segment")); + } + let (canonical, _) = normalize_url_component( + segment, + label, + is_route_path_component_char, + is_route_path_component_char, + )?; + uhura_port::decode_opaque_path_component(&canonical) + .map_err(|_| format!("{label} contains a non-canonical route component"))?; + normalized.push(canonical); + } + let mut path = format!("/{}", normalized.join("/")); + if value.ends_with('/') { + path.push('/'); + } + Ok(path) +} + +fn normalize_origin_path( + value: &str, + label: &str, + directory: bool, + allow_encoded_slashes: bool, +) -> Result { + if !value.starts_with('/') || value.starts_with("//") || value.contains(['\\', '?', '#']) { + return Err(format!("{label} must be an origin-local path")); + } + if directory && !value.ends_with('/') { + return Err(format!("{label} must end with /")); + } + if value == "/" { + return Ok("/".to_string()); + } + let body = value + .strip_prefix('/') + .expect("origin-local path has a leading slash"); + let body = body.strip_suffix('/').unwrap_or(body); + let mut normalized = Vec::new(); + for segment in body.split('/') { + if segment.is_empty() { + return Err(format!("{label} contains an empty path segment")); + } + normalized.push(normalize_url_segment( + segment, + label, + allow_encoded_slashes, + )?); + } + let mut path = format!("/{}", normalized.join("/")); + if value.ends_with('/') { + path.push('/'); + } + Ok(path) +} + +fn normalize_url_segment( + segment: &str, + label: &str, + allow_encoded_slashes: bool, +) -> Result { + let (canonical, decoded) = + normalize_url_component(segment, label, is_path_segment_char, is_unreserved)?; + if decoded == "." || decoded == ".." || decoded.contains('\\') { + return Err(format!("{label} contains an unsafe path segment")); + } + if decoded.contains('/') + && (!allow_encoded_slashes + || decoded + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | ".."))) + { + return Err(format!("{label} contains an unsafe path segment")); + } + Ok(canonical) +} + +fn normalize_play_entry_suffix(value: &str, label: &str) -> Result { + if value.is_empty() { + return Ok(String::new()); + } + if let Some(query) = value.strip_prefix('?') { + let (query, fragment) = query + .split_once('#') + .map_or((query, None), |(query, fragment)| (query, Some(fragment))); + let mut normalized = normalize_route_query(query, label)?; + if let Some(fragment) = fragment { + let fragment = normalize_fragment(fragment, label)?; + if !fragment.is_empty() { + normalized.push('#'); + normalized.push_str(&fragment); + } + } + return Ok(normalized); + } + if let Some(fragment) = value.strip_prefix('#') { + let fragment = normalize_fragment(fragment, label)?; + return Ok(if fragment.is_empty() { + String::new() + } else { + format!("#{fragment}") + }); + } + Err(format!("{label} has an invalid URL suffix")) +} + +fn normalize_route_query(query: &str, label: &str) -> Result { + if query.is_empty() { + return Ok(String::new()); + } + let mut normalized = Vec::new(); + for pair in query.split('&') { + if pair.is_empty() { + return Err(format!("{label} query contains an empty pair")); + } + let Some((key, value)) = pair.split_once('=') else { + return Err(format!("{label} query pairs must contain `=`")); + }; + if key.is_empty() || value.contains('=') { + return Err(format!("{label} query contains a malformed pair")); + } + let key = normalize_route_query_component(key, label)?; + let value = normalize_route_query_component(value, label)?; + normalized.push(format!("{key}={value}")); + } + Ok(format!("?{}", normalized.join("&"))) +} + +fn normalize_route_query_component(value: &str, label: &str) -> Result { + let (canonical, _) = normalize_url_component( + value, + label, + is_route_query_component_char, + is_route_query_component_char, + )?; + uhura_port::decode_query_value(&canonical) + .map_err(|_| format!("{label} contains a non-canonical route query component"))?; + Ok(canonical) +} + +fn normalize_fragment(value: &str, label: &str) -> Result { + normalize_url_component(value, label, is_url_suffix_char, is_unreserved) + .map(|(canonical, _)| canonical) +} + +fn normalize_url_component( + value: &str, + label: &str, + raw_allowed: fn(u8) -> bool, + escaped_raw_allowed: fn(u8) -> bool, +) -> Result<(String, String), String> { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut canonical = String::with_capacity(value.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'%' { + if !bytes[index].is_ascii() || !raw_allowed(bytes[index]) { + return Err(format!( + "{label} contains a character that must be percent-encoded" + )); + } + decoded.push(bytes[index]); + canonical.push(char::from(bytes[index])); + index += 1; + continue; + } + let Some(high) = bytes.get(index + 1).and_then(|byte| hex_value(*byte)) else { + return Err(format!("{label} contains an invalid percent escape")); + }; + let Some(low) = bytes.get(index + 2).and_then(|byte| hex_value(*byte)) else { + return Err(format!("{label} contains an invalid percent escape")); + }; + let byte = (high << 4) | low; + decoded.push(byte); + if escaped_raw_allowed(byte) { + canonical.push(char::from(byte)); + } else { + canonical.push('%'); + canonical.push(char::from(b"0123456789ABCDEF"[(byte >> 4) as usize])); + canonical.push(char::from(b"0123456789ABCDEF"[(byte & 0x0f) as usize])); + } + index += 3; + } + let decoded = String::from_utf8(decoded) + .map_err(|_| format!("{label} contains an invalid UTF-8 escape"))?; + if decoded.chars().any(char::is_control) { + return Err(format!("{label} contains an unsafe control character")); + } + Ok((canonical, decoded)) +} + +fn is_unreserved(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') +} + +fn is_route_path_component_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'-' | b'.' | b'_' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')' + ) +} + +fn is_route_query_component_char(byte: u8) -> bool { + is_route_path_component_char(byte) && byte != b'\'' +} + +fn is_path_segment_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'-' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + ) +} + +fn is_url_suffix_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'-' | b'.' + | b'_' + | b'~' + | b'!' + | b'$' + | b'&' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b':' + | b'@' + | b'/' + | b'?' + ) +} + +fn play_entry_path_is_reserved(pathname: &str) -> bool { + matches!(pathname, "/" | "/_uhura/editor" | "/_uhura/editor/") + || pathname == "/api" + || pathname.starts_with("/api/") + || pathname == "/assets" + || pathname.starts_with("/assets/") +} + +fn validate_reserved_output_paths(files: &[StaticWebFile]) -> Result<(), String> { + if files.iter().any(|file| file.path == BUNDLE_MANIFEST_PATH) { + return Err(format!( + "static Web payload uses reserved output path `{BUNDLE_MANIFEST_PATH}`" + )); + } + Ok(()) +} + +fn validate_play_entry_target( + files: &[StaticWebFile], + logical_play_entry: &str, +) -> Result<(), String> { + let boundary = logical_play_entry + .char_indices() + .find_map(|(index, character)| matches!(character, '?' | '#').then_some(index)) + .unwrap_or(logical_play_entry.len()); + let relative = logical_play_entry[..boundary] + .strip_prefix('/') + .expect("normalized Play entry is origin-local"); + if relative == BUNDLE_MANIFEST_PATH || files.iter().any(|file| file.path == relative) { + return Err(format!( + "Play entry `{}` selects an exported file instead of an application route", + &logical_play_entry[..boundary] + )); + } + Ok(()) +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn validate_browser_runtime(files: &[StaticWebFile]) -> Result<(), String> { + for required in [ + "api/play/static.json", + "api/play/wasm/uhura_wasm.js", + "api/play/wasm/uhura_wasm_bg.wasm", + ] { + if !files.iter().any(|file| file.path == required) { + return Err(format!( + "static Play export requires the browser runtime file `{required}`" + )); + } + } + Ok(()) +} + +fn validate_output(out: &Path) -> Result<(), String> { + if out.as_os_str().is_empty() || out == Path::new("/") { + return Err("refusing unsafe output directory".to_string()); + } + if out + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(format!( + "output directory may not contain `..`: {}", + out.display() + )); + } + let name = out.file_name().and_then(|name| name.to_str()).unwrap_or(""); + if name.is_empty() || matches!(name, "." | "..") { + return Err(format!("unsafe output directory: {}", out.display())); + } + if let Ok(metadata) = fs::symlink_metadata(out) + && (metadata.file_type().is_symlink() || !metadata.is_dir()) + { + return Err(format!( + "existing output must be a regular directory, not {}", + out.display() + )); + } + Ok(()) +} + +fn validate_output_topology(project: &Path, out: &Path) -> Result<(), String> { + let project = fs::canonicalize(project).map_err(|error| { + format!( + "could not resolve project root {}: {error}", + project.display() + ) + })?; + let out = resolve_output_path(out)?; + if project == out { + return Err("output directory must not replace the project root".to_string()); + } + if project.starts_with(&out) { + return Err("output directory must not contain the project root".to_string()); + } + if out.starts_with(&project) { + return Err("output directory must be outside the project root".to_string()); + } + Ok(()) +} + +fn resolve_output_path(out: &Path) -> Result { + let absolute = if out.is_absolute() { + out.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| format!("could not resolve current directory: {error}"))? + .join(out) + }; + let mut existing = absolute.as_path(); + let mut missing = Vec::new(); + while !existing.exists() { + let name = existing + .file_name() + .ok_or_else(|| format!("could not resolve output directory {}", out.display()))?; + missing.push(name.to_os_string()); + existing = existing + .parent() + .ok_or_else(|| format!("could not resolve output directory {}", out.display()))?; + } + let mut resolved = fs::canonicalize(existing).map_err(|error| { + format!( + "could not resolve output directory ancestor {}: {error}", + existing.display() + ) + })?; + for name in missing.into_iter().rev() { + resolved.push(name); + } + Ok(resolved) +} + +fn write_bundle( + out: &Path, + files: &[uhura_host::StaticWebFile], + manifest: &StaticBundleManifest, +) -> Result<(), String> { + let parent = out.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("system clock is before Unix epoch: {error}"))? + .as_nanos(); + let name = out + .file_name() + .and_then(|name| name.to_str()) + .expect("validated output has a UTF-8 name"); + let staging = parent.join(format!(".{name}.tmp-{}-{nonce}", std::process::id())); + let backup = parent.join(format!(".{name}.old-{}-{nonce}", std::process::id())); + fs::create_dir(&staging) + .map_err(|error| format!("could not create {}: {error}", staging.display()))?; + + let result = (|| { + for file in files { + let relative = validated_relative(&file.path)?; + let destination = staging.join(relative); + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + fs::write(&destination, &file.bytes) + .map_err(|error| format!("could not write {}: {error}", destination.display()))?; + } + let manifest_json = serde_json::to_vec_pretty(manifest) + .map_err(|error| format!("could not encode bundle manifest: {error}"))?; + fs::write(staging.join(BUNDLE_MANIFEST_PATH), manifest_json) + .map_err(|error| format!("could not write static bundle manifest: {error}"))?; + + let existed = out.exists(); + if existed { + fs::rename(out, &backup) + .map_err(|error| format!("could not stage existing {}: {error}", out.display()))?; + } + if let Err(error) = fs::rename(&staging, out) { + if existed { + let _ = fs::rename(&backup, out); + } + return Err(format!( + "could not publish static bundle to {}: {error}", + out.display() + )); + } + if existed { + fs::remove_dir_all(&backup).map_err(|error| { + format!( + "published bundle but could not remove {}: {error}", + backup.display() + ) + })?; + } + Ok(()) + })(); + + if staging.exists() { + let _ = fs::remove_dir_all(&staging); + } + result +} + +fn validated_relative(path: &str) -> Result<&Path, String> { + let relative = Path::new(path); + if path.is_empty() + || relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!("static file has an unsafe path: {path}")); + } + Ok(relative) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn runtime_file(path: &str) -> StaticWebFile { + StaticWebFile { + path: path.to_string(), + content_type: "application/octet-stream".to_string(), + bytes: vec![1], + } + } + + #[test] + fn export_output_must_be_disjoint_from_the_project_tree() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "uhura-export-topology-{}-{nonce}", + std::process::id() + )); + let project = root.join("project"); + fs::create_dir_all(&project).unwrap(); + + assert!(validate_output_topology(&project, &root.join("published")).is_ok()); + assert!(validate_output_topology(&project, &project).is_err()); + assert!(validate_output_topology(&project, &root).is_err()); + assert!(validate_output_topology(&project, &project.join("dist")).is_err()); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn static_export_requires_the_complete_browser_runtime() { + let metadata = runtime_file("api/play/static.json"); + let glue = runtime_file("api/play/wasm/uhura_wasm.js"); + let wasm = runtime_file("api/play/wasm/uhura_wasm_bg.wasm"); + + assert!(validate_browser_runtime(&[metadata.clone(), glue.clone(), wasm]).is_ok()); + let error = validate_browser_runtime(&[metadata, glue]).unwrap_err(); + assert!(error.contains("uhura_wasm_bg.wasm"), "{error}"); + } + + fn export_template_files() -> Vec { + let index = StaticWebFile { + path: "index.html".to_string(), + content_type: "text/html".to_string(), + bytes: format!( + r#"{HOST_CONFIG_OPEN}{{"protocol":"uhura-host-config/0","mountPath":"/","mode":"live","playEntry":"/play"}}"# + ) + .into_bytes(), + }; + let marker = StaticWebFile { + path: "uhura-web-build.json".to_string(), + content_type: "application/json".to_string(), + bytes: br#"{"protocol":"uhura-web-build/1","profile":"export-template","assetBase":"./","hostConfigProtocol":"uhura-host-config/0"}"#.to_vec(), + }; + vec![index, marker] + } + + #[test] + fn materializes_one_export_template_for_any_mount() { + let mut files = export_template_files(); + materialize_static_web( + &mut files, + "/products/uhura/", + "/orders/100?step=items", + "/products/uhura/orders/100?step=items", + ) + .unwrap(); + validate_materialized_web( + &files, + "/products/uhura/", + "/products/uhura/orders/100?step=items", + ) + .unwrap(); + + let index = files.iter().find(|file| file.path == "index.html").unwrap(); + let index = std::str::from_utf8(&index.bytes).unwrap(); + assert!(index.contains("src=\"/products/uhura/assets/app.js\"")); + assert!(index.contains( + r#""mountPath":"/products/uhura/","mode":"static","playEntry":"/orders/100?step=items""# + )); + let marker: serde_json::Value = serde_json::from_slice( + &files + .iter() + .find(|file| file.path == "uhura-web-build.json") + .unwrap() + .bytes, + ) + .unwrap(); + assert_eq!(marker["profile"], "static-export"); + assert_eq!(marker["playEntry"], "/products/uhura/orders/100?step=items"); + + let mut root_files = export_template_files(); + materialize_static_web(&mut root_files, "/", "/play", "/play").unwrap(); + validate_materialized_web(&root_files, "/", "/play").unwrap(); + let root_index = root_files + .iter() + .find(|file| file.path == "index.html") + .unwrap(); + assert!( + std::str::from_utf8(&root_index.bytes) + .unwrap() + .contains("src=\"/assets/app.js\"") + ); + + let mut escaped_files = export_template_files(); + materialize_static_web( + &mut escaped_files, + "/research&proof/", + "/play", + "/research&proof/play", + ) + .unwrap(); + let escaped_index = escaped_files + .iter() + .find(|file| file.path == "index.html") + .unwrap(); + assert!( + std::str::from_utf8(&escaped_index.bytes) + .unwrap() + .contains("src=\"/research&proof/assets/app.js\"") + ); + } + + #[test] + fn static_export_requires_the_dedicated_export_template() { + let index = export_template_files().remove(0); + let error = validate_export_web_template(&[index]).unwrap_err(); + assert!(error.contains("packaged export Web template"), "{error}"); + + let mut files = export_template_files(); + files + .iter_mut() + .find(|file| file.path == "uhura-web-build.json") + .unwrap() + .bytes = br#"{"protocol":"uhura-web-build/1","profile":"live","assetBase":"/","hostConfigProtocol":"uhura-host-config/0"}"#.to_vec(); + let error = validate_export_web_template(&files).unwrap_err(); + assert!(error.contains("profile `live`"), "{error}"); + } + + #[test] + fn mount_and_play_entry_paths_are_canonical_and_origin_local() { + assert_eq!(normalize_mount_path("/").unwrap(), "/"); + assert_eq!(normalize_mount_path("/demo").unwrap(), "/demo/"); + assert_eq!( + normalize_mount_path("/space%20name").unwrap(), + "/space%20name/" + ); + assert_eq!( + normalize_mount_path("/%eb%8d%b0%eb%aa%a8/").unwrap(), + "/%EB%8D%B0%EB%AA%A8/" + ); + assert_eq!(normalize_mount_path("/%41%3a/").unwrap(), "/A%3A/"); + assert_eq!( + normalize_play_entry("/orders/100?step=%69tems#sum%6dary").unwrap(), + "/orders/100?step=items#summary" + ); + assert_eq!( + normalize_play_entry("/orders/%E2%82%AC?note=%27ok%27").unwrap(), + "/orders/%E2%82%AC?note=%27ok%27" + ); + assert_eq!( + normalize_play_entry("/search?q=a%26b%3Dc").unwrap(), + "/search?q=a%26b%3Dc" + ); + assert_eq!( + normalize_play_entry("/orders/return%2fwith%2fslash?step=review%2fconfirm%2b").unwrap(), + "/orders/return%2Fwith%2Fslash?step=review%2Fconfirm%2B" + ); + assert_eq!(normalize_play_entry("/author's").unwrap(), "/author's"); + + for invalid in [ + "demo", + "//example.com/", + "/demo//", + "/./", + "/../", + "/%2e%2e/", + "/a%2fb/", + "/a%5cb/", + "/demo/?query", + "/demo/#fragment", + "/demo\\", + "/demo space/", + "/데모/", + "/demo\"/", + "/demo`/", + "/%00/", + "/%7f/", + "/%c2%85/", + "/%ff/", + ] { + assert!( + normalize_mount_path(invalid).is_err(), + "accepted mount {invalid}" + ); + } + for invalid in [ + "/", + "/_uhura/editor", + "//example.com/play", + "/../play", + "/%2e%2e/play", + "/api", + "/api/play/config.json", + "/assets", + "/assets/app.js", + "/orders/", + "/orders/$identity", + "/play?query", + "/play?=value", + "/play?state=open&&step=review", + "/play?state=open=again", + "/play?unsafe=+", + "/play?unsafe='", + "/play?unsafe=\u{7f}", + "/play?unsafe=%C2%85", + "/play?unsafe=raw space", + "/play#unsafe#fragment", + ] { + assert!( + normalize_play_entry(invalid).is_err(), + "accepted Play entry {invalid}" + ); + } + } + + #[test] + fn export_reserves_its_manifest_and_application_entry_routes() { + let manifest = runtime_file(BUNDLE_MANIFEST_PATH); + let error = validate_reserved_output_paths(&[manifest]).unwrap_err(); + assert!(error.contains("reserved output path"), "{error}"); + + let files = [ + runtime_file("index.html"), + runtime_file("favicon.svg"), + runtime_file("api/play/config.json"), + ]; + for entry in [ + "/index.html", + "/favicon.svg?theme=dark", + "/uhura-static-bundle.json", + ] { + let error = validate_play_entry_target(&files, entry).unwrap_err(); + assert!(error.contains("exported file"), "{error}"); + } + assert!(validate_play_entry_target(&files, "/returns/100").is_ok()); + } + + #[test] + fn internal_resource_paths_allow_identity_slashes_but_not_traversal() { + assert_eq!( + normalize_origin_path("/api/play/assets/poster%2fone.jpg", "resource", false, true,) + .unwrap(), + "/api/play/assets/poster%2Fone.jpg" + ); + for path in [ + "/api/play/assets/%2e%2e/outside", + "/api/play/assets/poster%2F..%2Foutside", + "/api/play/assets/%2Foutside", + ] { + assert!( + normalize_origin_path(path, "resource", false, true).is_err(), + "accepted resource {path}" + ); + } + } +} diff --git a/crates/uhura-cli/src/cmd/mod.rs b/crates/uhura-cli/src/cmd/mod.rs index ba4bbb2..1aa0994 100644 --- a/crates/uhura-cli/src/cmd/mod.rs +++ b/crates/uhura-cli/src/cmd/mod.rs @@ -1,5 +1,6 @@ pub mod check; pub mod editor; +pub mod export; pub mod fmt; pub mod graph; pub mod play; diff --git a/crates/uhura-cli/src/cmd/play.rs b/crates/uhura-cli/src/cmd/play.rs index a0433b9..a446f2d 100644 --- a/crates/uhura-cli/src/cmd/play.rs +++ b/crates/uhura-cli/src/cmd/play.rs @@ -304,7 +304,7 @@ fn run_host(common: &CommonArgs, port: u16, primary: PrimarySurface) -> ExitCode ExitCode::SUCCESS } -fn locate_web_assets() -> Result { +pub(super) fn locate_web_assets() -> Result { let mut candidates = Vec::new(); if let Some(explicit) = std::env::var_os("UHURA_WEB_DIST") { candidates.push(PathBuf::from(explicit)); @@ -349,6 +349,52 @@ fn locate_web_assets() -> Result { )) } +pub(super) fn locate_export_web_assets() -> Result { + let mut candidates = Vec::new(); + if let Some(explicit) = std::env::var_os("UHURA_EXPORT_WEB_DIST") { + candidates.push(PathBuf::from(explicit)); + } + if let Ok(executable) = std::env::current_exe() + && let Some(bin) = executable.parent() + { + candidates.push(bin.join("../share/uhura/web-export")); + } + candidates.push(tool_root().join("web/dist-export")); + + let mut attempted = Vec::new(); + for root in candidates { + if attempted.contains(&root) { + continue; + } + attempted.push(root.clone()); + let index_path = root.join("index.html"); + match std::fs::symlink_metadata(&index_path) { + Ok(_) => { + let wasm_root = locate_wasm_for(&root); + return if wasm_root.is_dir() { + WebAssets::from_directories(&root, &wasm_root) + } else { + WebAssets::from_frontend_directory(&root) + }; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("could not read {}: {error}", index_path.display())); + } + } + } + let locations = attempted + .iter() + .map(|root| root.join("index.html").display().to_string()) + .collect::>() + .join(", "); + Err(format!( + "export browser application is not built (looked for {locations}); set \ + UHURA_EXPORT_WEB_DIST or build the export Web profile into web/dist-export before \ + running `uhura export`" + )) +} + fn locate_wasm_for(web_root: &Path) -> PathBuf { if let Some(explicit) = std::env::var_os("UHURA_WASM_DIST") { return PathBuf::from(explicit); @@ -435,7 +481,7 @@ fn observe(root: std::path::PathBuf, host: Arc, mut seen: ProjectSourceFin } } -fn project_fingerprint(root: &Path) -> ProjectSourceFingerprint { +pub(super) fn project_fingerprint(root: &Path) -> ProjectSourceFingerprint { capture_project_snapshot(root).fingerprint().clone() } @@ -453,7 +499,7 @@ fn wait_for_stable_fingerprint( } } -fn build_stable_candidate( +pub(super) fn build_stable_candidate( root: &Path, mut before: ProjectSourceFingerprint, revision: u64, diff --git a/crates/uhura-cli/src/main.rs b/crates/uhura-cli/src/main.rs index 9f6e441..e588e8a 100644 --- a/crates/uhura-cli/src/main.rs +++ b/crates/uhura-cli/src/main.rs @@ -1,7 +1,7 @@ -//! The `uhura` CLI: check | fmt | editor | play | trace | graph. With no command -//! it opens the read-only editor for the current directory. Thin argument -//! parsing over the library crate (`uhura_cli::cmd`) — the same code the gate -//! tests drive. +//! The `uhura` CLI: check | export | fmt | editor | play | trace | graph. With +//! no command it opens the read-only editor for the current directory. Thin +//! argument parsing over the library crate (`uhura_cli::cmd`) — the same code +//! the gate tests drive. use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -11,6 +11,7 @@ use uhura_cli::{CommonArgs, cmd}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CliCommand { Check, + Export, Fmt, Editor, Play, @@ -22,6 +23,7 @@ impl CliCommand { fn parse(name: &str) -> Option { match name { "check" => Some(Self::Check), + "export" => Some(Self::Export), "fmt" => Some(Self::Fmt), "editor" => Some(Self::Editor), "play" => Some(Self::Play), @@ -77,6 +79,8 @@ fn main() -> ExitCode { let mut emit_ir = false; let mut script: Option = None; let mut out: Option = None; + let mut mount: Option = None; + let mut play_entry: Option = None; let mut expanded = false; let mut port: u16 = 8787; @@ -110,6 +114,26 @@ fn main() -> ExitCode { other if other.starts_with("--out=") => { out = Some(other["--out=".len()..].to_string()); } + other if other.starts_with("--mount=") => { + mount = Some(other["--mount=".len()..].to_string()); + } + "--mount" => match args.next() { + Some(v) => mount = Some(v), + None => { + eprintln!("--mount takes an origin-local path"); + return ExitCode::from(2); + } + }, + other if other.starts_with("--play-entry=") => { + play_entry = Some(other["--play-entry=".len()..].to_string()); + } + "--play-entry" => match args.next() { + Some(v) => play_entry = Some(v), + None => { + eprintln!("--play-entry takes an origin-local path"); + return ExitCode::from(2); + } + }, // Space-separated `--out ` consumes its value, like --format. "--out" => match args.next() { Some(v) => out = Some(v), @@ -150,9 +174,19 @@ fn main() -> ExitCode { deny_warnings, emit_ir, }; + if command != CliCommand::Export && (mount.is_some() || play_entry.is_some()) { + eprintln!("--mount and --play-entry are valid only for `uhura export`"); + return ExitCode::from(2); + } match command { CliCommand::Fmt => cmd::fmt::run(&common, check_only), CliCommand::Check => cmd::check::run(&common), + CliCommand::Export => cmd::export::run( + &common, + out.as_deref(), + mount.as_deref(), + play_entry.as_deref(), + ), CliCommand::Editor => cmd::editor::run(&common, port), CliCommand::Trace => cmd::trace::run(&common, script.as_deref(), expanded), CliCommand::Play => cmd::play::run(&common, port), @@ -162,7 +196,11 @@ fn main() -> ExitCode { fn print_usage() { eprintln!("usage: uhura [path] [--port ]"); - eprintln!(" uhura [path] [flags]"); + eprintln!(" uhura [path] [flags]"); + eprintln!( + " uhura export [path] --out [--mount ] \ + [--play-entry ]" + ); eprintln!(" no command selects the editor (path defaults to the current directory)"); } @@ -196,6 +234,14 @@ mod tests { assert_eq!(select_command(Some("play")), Ok((CliCommand::Play, true))); } + #[test] + fn selects_static_export_as_an_explicit_command() { + assert_eq!( + select_command(Some("export")), + Ok((CliCommand::Export, true)) + ); + } + #[test] fn command_like_typos_still_report_as_unknown_commands() { assert_eq!( diff --git a/crates/uhura-cli/tests/export.rs b/crates/uhura-cli/tests/export.rs new file mode 100644 index 0000000..fdaf116 --- /dev/null +++ b/crates/uhura-cli/tests/export.rs @@ -0,0 +1,375 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uhura_base::sha256_hex; + +const MANIFEST_PATH: &str = "uhura-static-bundle.json"; +const WEB_SENTINEL: &str = "export const packagedWeb = true;"; +const WASM_JS_SENTINEL: &str = "export default async()=>({packaged:true});"; +const WASM_BINARY_SENTINEL: &[u8] = b"packaged-wasm"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExportManifest { + bundle_id: String, + mount_path: String, + play_entry: String, + entry_document: String, + history_fallback: Value, + files: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ManifestFile { + path: String, + sha256: String, + bytes: usize, + content_type: String, +} + +fn temporary_root() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "uhura-cli-export-test-{}-{nonce}", + std::process::id() + )) +} + +fn write_export_template(root: &Path, profile: &str) { + fs::create_dir_all(root.join("assets")).unwrap(); + fs::write( + root.join("index.html"), + r#"
"#, + ) + .unwrap(); + fs::write(root.join("assets/app.js"), WEB_SENTINEL).unwrap(); + fs::write( + root.join("uhura-web-build.json"), + format!( + r#"{{"protocol":"uhura-web-build/1","profile":"{profile}","assetBase":"./","hostConfigProtocol":"uhura-host-config/0"}}"# + ), + ) + .unwrap(); +} + +fn write_wasm(root: &Path) { + fs::create_dir_all(root).unwrap(); + fs::write(root.join("uhura_wasm.js"), WASM_JS_SENTINEL).unwrap(); + fs::write(root.join("uhura_wasm_bg.wasm"), WASM_BINARY_SENTINEL).unwrap(); +} + +fn assemble_package(root: &Path) -> PathBuf { + let package = root.join("package"); + let binary = package.join("bin/uhura"); + fs::create_dir_all(binary.parent().unwrap()).unwrap(); + let source_binary = Path::new(env!("CARGO_BIN_EXE_uhura")); + fs::copy(source_binary, &binary).unwrap(); + fs::set_permissions(&binary, fs::metadata(source_binary).unwrap().permissions()).unwrap(); + write_export_template(&package.join("share/uhura/web-export"), "export-template"); + write_wasm(&package.join("share/uhura/wasm")); + package +} + +fn write_test_project(root: &Path) -> PathBuf { + let project = root.join("project"); + fs::create_dir_all(project.join("app")).unwrap(); + fs::write( + project.join("uhura.toml"), + r#"[project] +name = "test.export" +version = 1 +language = "0.4" + +[framework] +profile = "web-app" +version = 1 +machine = "crate::program::App" +location = "crate::routing::Location" + +[modules] +program = "machine.uhura" +routing = "routing.uhura" +"#, + ) + .unwrap(); + fs::write( + project.join("routing.uhura"), + "pub enum Location { Home }\n", + ) + .unwrap(); + fs::write( + project.join("machine.uhura"), + r#"use uhura::web_router::Router; +use crate::framework::routes::APPLICATION_ROUTES; +use crate::routing::Location; + +pub machine App { + port router = Router { routes: APPLICATION_ROUTES }; + events { Refresh } + outcomes { commit Accepted } + state { location: Option = None } + observe { location } + on Refresh { Accepted } + on router.Changed(next) { + location = Some(next); + Accepted + } +} +"#, + ) + .unwrap(); + fs::write( + project.join("app/page.uhura"), + r#"use uhura::ui; +use crate::program::App; + +pub ui HomePage for App(view) { +
Home
+} +"#, + ) + .unwrap(); + fs::write( + project.join("app/page.examples.uhura"), + r#"use uhura::web_router::Router; +use crate::framework::routes::APPLICATION_ROUTES; +use crate::program::App; +use crate::app::HomePage; + +scenario home_scenario for App { + bind router = Router.fixture(APPLICATION_ROUTES) + start + pin frame +} + +example home + for HomePage as page default + = home_scenario::frame; +"#, + ) + .unwrap(); + fs::write( + project.join("host.toml"), + r#"[entry.app] +machine = "crate::App" +presentation = "crate::Application" +lifetime = "application-session" + +[entry.app.ports] +router = "web.history" +"#, + ) + .unwrap(); + project +} + +fn run_export(package: &Path, project: &Path, out: &Path) -> std::process::Output { + Command::new(package.join("bin/uhura")) + .current_dir(package) + .env_remove("UHURA_EXPORT_WEB_DIST") + .env_remove("UHURA_WEB_DIST") + .env_remove("UHURA_WASM_DIST") + .arg("export") + .arg(project) + .arg("--out") + .arg(out) + .arg("--mount") + .arg("/products/uhura/") + .arg("--play-entry") + .arg("/returns?status=open") + .output() + .unwrap() +} + +fn collect_files(root: &Path, directory: &Path, files: &mut Vec) { + for entry in fs::read_dir(directory).unwrap() { + let entry = entry.unwrap(); + let file_type = entry.file_type().unwrap(); + if file_type.is_dir() { + collect_files(root, &entry.path(), files); + } else { + assert!(file_type.is_file(), "export contains a non-file entry"); + let relative = entry.path().strip_prefix(root).unwrap().to_path_buf(); + files.push( + relative + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"), + ); + } + } +} + +fn file_paths(root: &Path) -> Vec { + let mut files = Vec::new(); + collect_files(root, root, &mut files); + files.sort(); + files +} + +fn directory_snapshot(root: &Path) -> BTreeMap { + file_paths(root) + .into_iter() + .map(|path| { + let digest = sha256_hex(&fs::read(root.join(&path)).unwrap()); + (path, digest) + }) + .collect() +} + +fn read_manifest(out: &Path) -> (Vec, ExportManifest) { + let bytes = fs::read(out.join(MANIFEST_PATH)).unwrap(); + let manifest = serde_json::from_slice(&bytes).unwrap(); + (bytes, manifest) +} + +fn assert_exact_manifest(out: &Path, manifest: &ExportManifest) { + let declared = manifest + .files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + assert!( + declared.windows(2).all(|pair| pair[0] < pair[1]), + "manifest paths must be unique and strictly sorted" + ); + assert!(!declared.iter().any(|path| path == MANIFEST_PATH)); + + let actual = file_paths(out) + .into_iter() + .filter(|path| path != MANIFEST_PATH) + .collect::>(); + assert_eq!( + actual, declared, + "manifest must inventory every payload file" + ); + + for file in &manifest.files { + let bytes = fs::read(out.join(&file.path)).unwrap(); + assert_eq!( + file.bytes, + bytes.len(), + "wrong byte length for {}", + file.path + ); + assert_eq!( + file.sha256, + sha256_hex(&bytes), + "wrong digest for {}", + file.path + ); + } + assert_eq!( + manifest.bundle_id, + sha256_hex(&serde_json::to_vec(&manifest.files).unwrap()), + "bundle identity must cover the exact ordered inventory" + ); +} + +#[test] +fn packaged_cli_export_materializes_and_replaces_one_verified_bundle() { + let root = temporary_root(); + let package = assemble_package(&root); + let project = write_test_project(&root); + let out = root.join("published"); + + let nested_out = project.join("dist"); + let rejected_nested = run_export(&package, &project, &nested_out); + assert!(!rejected_nested.status.success()); + assert!(String::from_utf8_lossy(&rejected_nested.stderr).contains("outside the project root")); + assert!(!nested_out.exists()); + + let output = run_export(&package, &project, &out); + assert!( + output.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + for required in [ + "index.html", + "assets/app.js", + "api/editor/state", + "api/play/ir.json", + "api/play/static.json", + "api/play/wasm/uhura_wasm.js", + "api/play/wasm/uhura_wasm_bg.wasm", + MANIFEST_PATH, + ] { + assert!(out.join(required).is_file(), "missing {required}"); + } + assert!(!out.join("api/editor/events").exists()); + assert!(!out.join("api/play/events").exists()); + assert_eq!( + fs::read_to_string(out.join("assets/app.js")).unwrap(), + WEB_SENTINEL + ); + assert_eq!( + fs::read_to_string(out.join("api/play/wasm/uhura_wasm.js")).unwrap(), + WASM_JS_SENTINEL + ); + assert_eq!( + fs::read(out.join("api/play/wasm/uhura_wasm_bg.wasm")).unwrap(), + WASM_BINARY_SENTINEL + ); + + let index = fs::read_to_string(out.join("index.html")).unwrap(); + assert!(index.contains("src=\"/products/uhura/assets/app.js\"")); + assert!(index.contains(r#""mountPath":"/products/uhura/""#)); + assert!(index.contains(r#""mode":"static""#)); + assert!(index.contains(r#""playEntry":"/returns?status=open""#)); + + let editor: Value = + serde_json::from_slice(&fs::read(out.join("api/editor/state")).unwrap()).unwrap(); + let play: Value = + serde_json::from_slice(&fs::read(out.join("api/play/static.json")).unwrap()).unwrap(); + assert_eq!(editor["sourceRevision"], 1); + assert_eq!(editor["render"]["revision"], 1); + assert_eq!(play["playGeneration"], 1); + + let (manifest_bytes, manifest) = read_manifest(&out); + assert_eq!(manifest.mount_path, "/products/uhura/"); + assert_eq!(manifest.play_entry, "/products/uhura/returns?status=open"); + assert_eq!(manifest.entry_document, "index.html"); + assert_eq!(manifest.history_fallback["scope"], "/products/uhura/"); + assert_exact_manifest(&out, &manifest); + + fs::write(out.join("obsolete.txt"), "old generation").unwrap(); + let repeated = run_export(&package, &project, &out); + assert!( + repeated.status.success(), + "stderr:\n{}", + String::from_utf8_lossy(&repeated.stderr) + ); + assert!(!out.join("obsolete.txt").exists()); + let (repeated_manifest_bytes, repeated_manifest) = read_manifest(&out); + assert_eq!( + repeated_manifest_bytes, manifest_bytes, + "identical inputs and topology must produce one manifest" + ); + assert_eq!(repeated_manifest.bundle_id, manifest.bundle_id); + assert_exact_manifest(&out, &repeated_manifest); + + let accepted_snapshot = directory_snapshot(&out); + write_export_template(&package.join("share/uhura/web-export"), "live"); + let rejected = run_export(&package, &project, &out); + assert!(!rejected.status.success()); + assert!(String::from_utf8_lossy(&rejected.stderr).contains("profile `live`")); + assert_eq!( + directory_snapshot(&out), + accepted_snapshot, + "a rejected replacement must leave every published byte untouched" + ); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/uhura-host/src/lib.rs b/crates/uhura-host/src/lib.rs index d1978ff..a862407 100644 --- a/crates/uhura-host/src/lib.rs +++ b/crates/uhura-host/src/lib.rs @@ -5022,6 +5022,18 @@ pub struct WebAssetDigest { pub size: u64, } +/// One immutable file in a listenerless browser bundle. +/// +/// Paths are relative URL paths without a leading slash. A static publisher +/// may mount the complete set below one prefix; the matching web build uses +/// that prefix for shell navigation and API/resource requests. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StaticWebFile { + pub path: String, + pub content_type: String, + pub bytes: Vec, +} + #[derive(Clone, Debug)] struct WebFile { bytes: Arc>, @@ -5190,8 +5202,28 @@ fn validate_index_assets( root.join("index.html").display() )); } + let declared_mount = files + .get("uhura-web-build.json") + .and_then(|file| serde_json::from_slice::(&file.bytes).ok()) + .and_then(|value| { + value + .get("assetBase") + .or_else(|| value.get("base"))? + .as_str() + .map(str::to_string) + }) + .and_then(|base| { + let relative = base.strip_prefix('/')?.strip_suffix('/')?; + (!relative.is_empty()).then(|| format!("{relative}/")) + }); for reference in references { - if !files.contains_key(&reference) { + let packaged = files.contains_key(&reference) + || declared_mount.as_ref().is_some_and(|mount| { + reference + .strip_prefix(mount) + .is_some_and(|relative| files.contains_key(relative)) + }); + if !packaged { return Err(format!( "{} references a missing application asset: /{reference}", root.join("index.html").display() @@ -5360,7 +5392,10 @@ fn record_index_asset_reference( { return Ok(()); } - let relative = path.strip_prefix('/').unwrap_or(path); + let relative = path + .strip_prefix('/') + .or_else(|| path.strip_prefix("./")) + .unwrap_or(path); if relative.contains('\\') || relative .split('/') @@ -5644,6 +5679,203 @@ impl Host { }; served_response(outcome) } + + /// Snapshot the immutable Editor/Play application as ordinary files. + /// + /// Event streams are intentionally absent: a static web build pins one + /// checked revision and one Play generation for the browser session. + /// The caller is responsible for serving application-history paths with + /// `index.html` as their fallback. + pub fn static_files(&self) -> Result, String> { + let state = self.state.read().expect("state lock"); + if state.play.generation != state.editor.source_revision { + return Err(format!( + "cannot export an incoherent static bundle: Editor revision {} and Play generation {} differ", + state.editor.source_revision, state.play.generation, + )); + } + if !state.play.ok { + return Err(format!( + "cannot export static bundle: current Play generation {} is rejected", + state.play.generation, + )); + } + let renderable = state.editor.last_renderable.as_ref().ok_or_else(|| { + "cannot export a static bundle without a renderable Editor revision".to_string() + })?; + if renderable.render.revision != state.editor.source_revision { + return Err(format!( + "cannot export static bundle: current Editor revision {} is not renderable; latest renderable revision is {}", + state.editor.source_revision, renderable.render.revision, + )); + } + let good = + state.play.good.as_ref().ok_or_else(|| { + "cannot export a static bundle without a good Play build".to_string() + })?; + let mut files = BTreeMap::::new(); + + for (path, file) in self.web.files.iter() { + insert_static_file( + &mut files, + path.clone(), + file.content_type.clone(), + file.bytes.as_ref().clone(), + )?; + } + for (path, file) in self.web.wasm_files.iter() { + insert_static_file( + &mut files, + format!("api/play/wasm/{path}"), + file.content_type.clone(), + file.bytes.as_ref().clone(), + )?; + } + + insert_static_file( + &mut files, + "api/editor/state".to_string(), + content_type("json"), + state.editor.state_json.clone().into_bytes(), + )?; + let editor_fonts = renderable.icon_fonts.as_ref().map_or_else( + || { + empty_icon_font_manifest(IconFontManifestVersion::Revision( + renderable.render.revision, + )) + }, + |resources| { + icon_font_manifest( + resources, + IconFontManifestVersion::Revision(renderable.render.revision), + "/api/editor/icon-fonts", + ) + }, + ); + insert_static_file( + &mut files, + "api/editor/icon-fonts.json".to_string(), + content_type("json"), + editor_fonts, + )?; + if let Some(resources) = &renderable.icon_fonts { + insert_static_icon_fonts(&mut files, "api/editor/icon-fonts", resources)?; + } + + for (path, extension, bytes) in [ + ("api/play/ir.json", "json", good.ir.as_bytes()), + ( + "api/play/inspect.json", + "json", + good.inspect_json.as_bytes(), + ), + ("api/play/config.json", "json", good.config_json.as_bytes()), + ("api/play/stylesheet.css", "css", good.stylesheet.as_bytes()), + ] { + insert_static_file( + &mut files, + path.to_string(), + content_type(extension), + bytes.to_vec(), + )?; + } + insert_static_file( + &mut files, + "api/play/static.json".to_string(), + content_type("json"), + serde_json::to_vec(&serde_json::json!({ + "protocol": "uhura-static-play/0", + "playGeneration": state.play.generation, + })) + .map_err(|error| format!("could not encode static Play metadata: {error}"))?, + )?; + let play_fonts = good.icon_fonts.as_ref().map_or_else( + || empty_icon_font_manifest(IconFontManifestVersion::Generation(state.play.generation)), + |resources| { + icon_font_manifest( + resources, + IconFontManifestVersion::Generation(state.play.generation), + "/api/play/icon-fonts", + ) + }, + ); + insert_static_file( + &mut files, + "api/play/icon-fonts.json".to_string(), + content_type("json"), + play_fonts, + )?; + if let Some(resources) = &good.icon_fonts { + insert_static_icon_fonts(&mut files, "api/play/icon-fonts", resources)?; + } + if let Some(provider) = &good.provider_js { + insert_static_file( + &mut files, + "api/play/provider.js".to_string(), + content_type("js"), + provider.clone().into_bytes(), + )?; + } + for (path, bytes) in &good.play_assets { + let extension = Path::new(path) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or(""); + insert_static_file( + &mut files, + format!("api/play/assets/{path}"), + content_type(extension), + bytes.as_ref().to_vec(), + )?; + } + + Ok(files.into_values().collect()) + } +} + +fn insert_static_icon_fonts( + files: &mut BTreeMap, + root: &str, + resources: &IconFontResources, +) -> Result<(), String> { + for family in resources.families.values() { + insert_static_file( + files, + format!("{root}/{}.woff2", family.font_hash), + content_type("woff2"), + family.font.as_ref().to_vec(), + )?; + } + Ok(()) +} + +fn insert_static_file( + files: &mut BTreeMap, + path: String, + content_type: String, + bytes: Vec, +) -> Result<(), String> { + let candidate = StaticWebFile { + path: path.clone(), + content_type, + bytes, + }; + match files.entry(path) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(candidate); + Ok(()) + } + std::collections::btree_map::Entry::Occupied(entry) + if entry.get().content_type == candidate.content_type + && entry.get().bytes == candidate.bytes => + { + Ok(()) + } + std::collections::btree_map::Entry::Occupied(entry) => Err(format!( + "static bundle path `{}` has conflicting representations", + entry.key() + )), + } } fn finalize_route_response(method: RequestMethod, mut response: RouteResponse) -> RouteResponse { @@ -9990,6 +10222,131 @@ ui = "ui.uhura" assert_eq!(editor.as_slice(), b"
Uhura
"); } + #[test] + fn static_snapshot_contains_fixed_editor_play_web_and_wasm_files_without_events() { + let index = b"".to_vec(); + let web = WebAssets { + files: Arc::new(BTreeMap::from([ + ( + "index.html".to_string(), + WebFile { + bytes: Arc::new(index.clone()), + content_type: content_type("html"), + }, + ), + ( + "assets/app.js".to_string(), + WebFile { + bytes: Arc::new(b"export {};".to_vec()), + content_type: content_type("js"), + }, + ), + ])), + index: Arc::new(index), + wasm_files: Arc::new(BTreeMap::from([( + "uhura_wasm_bg.wasm".to_string(), + WebFile { + bytes: Arc::new(b"wasm".to_vec()), + content_type: content_type("wasm"), + }, + )])), + }; + let host = Host::new(web, candidate_with_icon_fonts(1, b"font")) + .unwrap() + .0; + let files = host.static_files().unwrap(); + let paths = files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + + for path in [ + "index.html", + "assets/app.js", + "api/editor/state", + "api/editor/icon-fonts.json", + "api/play/ir.json", + "api/play/inspect.json", + "api/play/config.json", + "api/play/static.json", + "api/play/icon-fonts.json", + "api/play/stylesheet.css", + "api/play/wasm/uhura_wasm_bg.wasm", + ] { + assert!(paths.contains(path), "missing {path}"); + } + for scope in ["editor", "play"] { + assert!( + paths.iter().any(|path| { + path.starts_with(&format!("api/{scope}/icon-fonts/")) + && path.ends_with(".woff2") + }), + "missing {scope} font bytes" + ); + } + assert!(!paths.contains("api/editor/events")); + assert!(!paths.contains("api/play/events")); + } + + #[test] + fn static_snapshot_rejects_current_editor_mixed_with_last_good_play() { + let host = Host::new( + test_web_assets(), + candidate_with_icon_fonts(1, b"first-font"), + ) + .unwrap() + .0; + let report = host + .publish(ClientCandidate { + revision: 2, + source_fingerprint: ProjectSourceFingerprint::default(), + source_revision_id: "test-source-revision-2".into(), + editor: Ok(artifact(2, "current-editor")), + play: Err(diagnostics("broken Play")), + checked_routes: Some(Vec::new()), + }) + .unwrap(); + assert!(report.editor_current); + assert!(!report.play_ok); + assert!(report.has_good_play); + + let error = host.static_files().unwrap_err(); + assert!( + error.contains("current Play generation 2 is rejected"), + "{error}" + ); + } + + #[test] + fn static_snapshot_rejects_stale_editor_mixed_with_current_play() { + let host = Host::new( + test_web_assets(), + candidate_with_icon_fonts(1, b"first-font"), + ) + .unwrap() + .0; + let report = host + .publish(ClientCandidate { + revision: 2, + source_fingerprint: ProjectSourceFingerprint::default(), + source_revision_id: "test-source-revision-2".into(), + editor: Err(editor_rejection("broken Editor")), + play: Ok(good_build(icon_fonts(b"second-font"))), + checked_routes: Some(Vec::new()), + }) + .unwrap(); + assert!(!report.editor_current); + assert!(report.play_ok); + + let error = host.static_files().unwrap_err(); + assert!( + error.contains( + "current Editor revision 2 is not renderable; latest renderable revision is 1" + ), + "{error}", + ); + } + #[test] fn api_routes_are_explicit_and_play_is_fully_namespaced() { assert_eq!(api_route("/api/editor/state"), Some(ApiRoute::EditorState)); @@ -11213,13 +11570,18 @@ glyphs = "icons/brand/missing.json" > + "#, ) .unwrap(); assert_eq!( references, - BTreeSet::from(["assets/app.css".to_string(), "assets/app.js".to_string(),]) + BTreeSet::from([ + "assets/app.css".to_string(), + "assets/app.js".to_string(), + "assets/export.js".to_string(), + ]) ); } @@ -11347,6 +11709,35 @@ glyphs = "icons/brand/missing.json" fs::remove_dir_all(root).unwrap(); } + #[test] + fn frontend_locator_accepts_assets_materialized_for_a_static_mount_path() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "uhura-web-mounted-assets-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(root.join("assets")).unwrap(); + fs::write( + root.join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(root.join("assets/app.js"), "application").unwrap(); + fs::write( + root.join("uhura-web-build.json"), + r#"{"protocol":"uhura-web-build/1","profile":"static-export","assetBase":"/demo/","hostConfigProtocol":"uhura-host-config/0","mountPath":"/demo/","playEntry":"/demo/play"}"#, + ) + .unwrap(); + + let web = WebAssets::from_frontend_directory(&root).unwrap(); + assert!(web.files.contains_key("assets/app.js")); + + fs::remove_dir_all(root).unwrap(); + } + #[cfg(unix)] #[test] fn frontend_locator_rejects_unsafe_bundle_entries_and_paths() { diff --git a/crates/uhura-port/src/route.rs b/crates/uhura-port/src/route.rs index 4140d33..34daac2 100644 --- a/crates/uhura-port/src/route.rs +++ b/crates/uhura-port/src/route.rs @@ -647,7 +647,7 @@ fn parse_pattern( format!("`{segment}` is not a complete path placeholder"), )); } - let decoded = decode_query_value(segment).map_err(|_| { + let decoded = decode_path_literal(segment).map_err(|_| { RouteError::for_route( RouteErrorCode::InvalidPattern, &constructor.name, @@ -860,7 +860,7 @@ fn parse_query(query: Option<&str>) -> Result, RouteError> /// Encodes a dynamic path value with the pinned opaque component codec. pub fn encode_opaque_path_component(value: &str) -> String { - let encoded = encode_url_component(value); + let encoded = encode_url_component(value, safe_path_component_byte); if encoded.is_empty() || encoded == "." || encoded == ".." || encoded.starts_with('~') { format!("~{}", encode_base64url(value.as_bytes())) } else { @@ -892,7 +892,7 @@ pub fn decode_opaque_path_component(component: &str) -> Result String { - encode_url_component(value) + encode_url_component(value, safe_query_component_byte) } /// Decodes a query component and rejects every non-canonical spelling. @@ -907,10 +907,21 @@ pub fn decode_query_value(component: &str) -> Result { Ok(decoded) } -fn encode_url_component(value: &str) -> String { +fn decode_path_literal(component: &str) -> Result { + let decoded = decode_percent_bytes(component)?; + if encode_url_component(&decoded, safe_path_component_byte) != component { + return Err(RouteError::new( + RouteErrorCode::NonCanonicalComponent, + format!("`{component}` is not a canonical literal path component"), + )); + } + Ok(decoded) +} + +fn encode_url_component(value: &str, safe: fn(u8) -> bool) -> String { let mut output = String::new(); for byte in value.as_bytes() { - if safe_component_byte(*byte) { + if safe(*byte) { output.push(char::from(*byte)); } else { const HEX: &[u8; 16] = b"0123456789ABCDEF"; @@ -922,7 +933,7 @@ fn encode_url_component(value: &str) -> String { output } -fn safe_component_byte(byte: u8) -> bool { +fn safe_path_component_byte(byte: u8) -> bool { matches!( byte, b'A'..=b'Z' @@ -940,6 +951,10 @@ fn safe_component_byte(byte: u8) -> bool { ) } +fn safe_query_component_byte(byte: u8) -> bool { + safe_path_component_byte(byte) && byte != b'\'' +} + fn decode_percent_bytes(component: &str) -> Result { let bytes = component.as_bytes(); let mut output = Vec::with_capacity(bytes.len()); @@ -1288,6 +1303,7 @@ mod tests { "plain", "space value", "slash/percent%", + "'", "~", "한글", "emoji-🛰️", @@ -1305,6 +1321,12 @@ mod tests { for noncanonical in ["raw space", "+", "%41", "%2f", "한글"] { assert!(decode_query_value(noncanonical).is_err(), "{noncanonical}"); } + assert_eq!(encode_opaque_path_component("'"), "'"); + assert_eq!(decode_path_literal("author's").unwrap(), "author's"); + assert!(decode_path_literal("author%27s").is_err()); + assert_eq!(encode_query_value("'"), "%27"); + assert_eq!(decode_query_value("%27").unwrap(), "'"); + assert!(decode_query_value("'").is_err()); } #[test] diff --git a/docs/README.md b/docs/README.md index 0ab3e9e..487037f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -68,6 +68,9 @@ compatibility version. - [RFC 0005](rfcs/0005-web-application-topology-and-ui-composition.md) fixes the opt-in Web application topology, generated checked routes, and pure UI-composition boundary incorporated by the active candidate. +- [RFC 0006](rfcs/0006-immutable-web-export.md) fixes host-agnostic immutable + Web export, listenerless static publication, and mount-scoped routing for a + checked project generation. - [RFC 0001](rfcs/0001-project-foundation.md) remains a draft proposal; it is not a foundational authority merely because other work was inspired by it. diff --git a/docs/rfcs/0006-immutable-web-export.md b/docs/rfcs/0006-immutable-web-export.md new file mode 100644 index 0000000..7cca581 --- /dev/null +++ b/docs/rfcs/0006-immutable-web-export.md @@ -0,0 +1,319 @@ +# RFC 0006: Immutable Web export + +- **Status:** Accepted +- **Implementation:** Implemented +- **Decision date:** 2026-07-23 +- **Scope:** Host-agnostic static export of one checked Editor/Play generation +- **Depends on:** + [RFC 0002](0002-model-driven-editor-live-updates.md) and + [RFC 0005](0005-web-application-topology-and-ui-composition.md) +- **Supersedes:** None +- **Does not select:** A hosting vendor, deployment workflow, public URL, + provider authority, or replacement development runtime + +## 1. Decision + +Uhura can export one checked project generation as an immutable directory of +ordinary Web files: + +```sh +uhura export [path] --out +``` + +Optional publication topology is selected when exporting: + +```sh +uhura export [path] \ + --out \ + --mount /products/uhura/ \ + --play-entry /orders/100 +``` + +The result contains the canonical Editor and Play browser application, the +checked Editor state, Play artifacts, Wasm runtime, admitted provider module, +fonts, and captured assets. It contains no watcher, compiler, event stream, or +Uhura server process. + +This is an **export** feature. Serving the result inside a documentation site, +artifact viewer, product page, or standalone origin is downstream publication +behavior. No one such embedding defines the feature. + +The export format is hosting-vendor agnostic, but a materialized artifact is +mount-specific. Moving it from `/products/uhura/` to another path requires a +new export. Uhura does not emit Vercel, Netlify, nginx, or other vendor +configuration. + +## 2. Why export is a separate runtime boundary + +Native Editor and Play are development surfaces. Their host observes source, +checks coherent captures, publishes replacement revisions, and announces +changes through server-sent events. + +An exported artifact has a different and deliberately smaller lifecycle: + +- it starts from one already-checked project capture; +- every browser session uses the same Editor revision and Play generation; +- it cannot observe later source changes; +- it runs the Play machine in browser Wasm; +- it may be served below a path owned by a larger site; and +- freshness means exporting and publishing a replacement directory. + +This does not restore the retired static Canvas or create a second renderer. +Export freezes the inputs of the same Editor/Play application used by the +native host. + +## 3. Distribution profiles + +One Uhura package carries two builds produced from the same Web source: + +| Packaged directory | Profile | Consumer | +| --- | --- | --- | +| `share/uhura/web/` | `live` | `uhura editor` and `uhura play` | +| `share/uhura/web-export/` | `export-template` | `uhura export` | + +The live profile retains the origin-root behavior of the native host. The +export template uses relative generated chunks and an explicit runtime host +configuration point. It is not itself a publishable project bundle. + +At export time, the CLI: + +1. locates the packaged export template and Wasm distribution; +2. checks one coherent project capture; +3. snapshots the current Editor and Play artifacts; +4. validates and materializes the requested mount and Play entry; +5. records the materialized Web topology; +6. inventories the exact payload bytes; and +7. stages and activates the output directory with rollback on activation + failure. + +Node, pnpm, and Vite are package-build dependencies. They are not dependencies +of `uhura export`, and export never rebuilds or overwrites the live Web +distribution. + +## 4. One current immutable snapshot + +Export uses the ordinary stable project-capture and candidate-build path. It +does not read source independently or define a weaker checker. + +Publication requires: + +1. a current renderable Editor revision; +2. a successful current Play generation; +3. equal Editor and Play publication revisions; +4. the recognized export-template Web profile; +5. the complete browser Wasm runtime; and +6. safe, non-conflicting output paths. + +The host rejects a stale last-renderable Editor revision and a retained +last-good Play build after a failed current publication. Therefore one export +cannot silently combine artifacts from different source revisions. + +The snapshot includes: + +- the compiled Web application and local chunks; +- the Wasm JavaScript loader and Wasm binary; +- complete Editor state and icon fonts; +- Play IR, inspection data, configuration, stylesheet, and icon fonts; +- the admitted provider module, when present; and +- captured Play assets. + +It excludes language source, compiler state, native process state, and Editor +or Play event endpoints. + +## 5. Browser host configuration + +`index.html` contains one typed runtime record: + +```json +{ + "protocol": "uhura-host-config/0", + "mountPath": "/products/uhura/", + "mode": "static", + "playEntry": "/orders/100" +} +``` + +The export template defaults to live root values only so it can be built and +validated. The exporter replaces that record and the template's controlled +entry-asset references before any output is published. Generated dynamic +chunks remain relative to their module URL, so emitted JavaScript is not +searched or rewritten. + +Static mode is explicit. The browser does not infer it from missing event +streams, failed requests, the current URL, or a particular hosting platform. + +Mounts use one canonical grammar: + +- `/` or an absolute origin-local path; +- exactly one slash between segments; +- a trailing slash in the materialized form (the CLI also accepts an omitted + trailing slash and adds it); +- raw ASCII RFC 3986 path-segment characters, with other valid UTF-8 bytes + percent-encoded; +- uppercase percent escapes in the materialized form, decoding only escaped + unreserved bytes while preserving escaped reserved bytes as route identity; + and +- no query, fragment, backslash, control character, dot segment, encoded dot + segment, or encoded path separator. + +`--play-entry` is an origin-local application path, optionally with query and +fragment. It is relative to the logical application root and is prefixed by +the selected mount. Path components and structured query pairs use the same +canonical codecs as the checked Router; fragments remain browser-only and are +not delivered to Router ingress. The entry must select Play rather than a +reserved Editor entry, transport namespace, compiled asset namespace, or real +exported file. + +## 6. Mount ownership + +A root export owns the ordinary Uhura topology: + +```text +/ +/play +/ +/api/editor/* +/api/play/* +``` + +An export mounted at `/products/uhura/` owns only: + +```text +/products/uhura/ +/products/uhura/play +/products/uhura/ +/products/uhura/api/editor/* +/products/uhura/api/play/* +``` + +The browser application strips the mount before route selection and restores +it when producing browser URLs. Editor/Play links, Uhura APIs, Wasm, fonts, and +captured assets stay below that mount. + +Same-origin links outside the mount are not intercepted by Uhura. Programmatic +Uhura navigation also rejects outside-mount destinations. Provider-owned and +site-owned URLs are not generally rebased; only the +`/api/play/assets/` namespace published by Uhura is treated as an Uhura asset +route. In static mode, encoded hierarchy separators in that captured-asset +identity are emitted as ordinary path separators, so the publisher does not +need special encoded-slash routing. + +## 7. Static browser behavior + +Editor fetches and renders the exported complete state through the canonical +projection renderer. It does not create an `EventSource`, and it does not show +a disconnected-live-preview warning because the artifact never promises live +updates. + +Play loads the same IR, inspection, provider configuration, stylesheet, fonts, +assets, and Wasm runtime as native Play. `api/play/static.json` supplies the +pinned generation when ordinary static-file responses do not carry the native +host's generation header. Play remains interactive for the page session, but +does not subscribe to a development-generation stream. + +The pinned generation prevents the browser from accepting a known +cross-generation API response inside one coherent publication. It cannot +detect a publisher or intermediary mixing files from separately published +directories, and the browser does not verify manifest hashes. Coherent +directory activation and cache invalidation remain publisher +responsibilities. + +Editor, Play, and application routes remain one single-page application. A +publisher must serve `index.html` as the history fallback for missing +document routes inside the declared mount while allowing real API and asset +files to win. + +## 8. Bundle records + +Three records have distinct identities: + +| File | Protocol | Responsibility | +| --- | --- | --- | +| `uhura-web-build.json` | `uhura-web-build/1` | Identifies the packaged profile, then records the materialized asset base, mount, Play entry, and host-config protocol. | +| `api/play/static.json` | `uhura-static-play/0` | Supplies the pinned Play generation for static responses. | +| `uhura-static-bundle.json` | `uhura-static-web-bundle/0` | Describes the exported artifact and its publication contract. | + +The bundle manifest records: + +- a deterministic bundle identity; +- checked source identity and tool version; +- mount and public Play entry; +- Editor revision and Play generation; +- preview counts; +- `index.html` as the entry document; +- a vendor-neutral history-fallback description; and +- every payload file's relative path, SHA-256 digest, byte length, and content + type. + +The manifest excludes itself from its inventory. It supports external +integrity and publication tooling; the current browser runtime does not verify +the inventory. + +## 9. Publication safety + +Export writes a sibling staging directory before activation. An existing +destination is moved aside only after the candidate is complete. The candidate +is then renamed into place, and activation failure attempts to restore the +previous destination. + +The output refuses root, parent traversal, an existing destination that is +itself a symlink, non-directory replacements, and any path equal to, above, or +below the captured project root. Symlinked ancestors are resolved before the +topology check. Keeping source and publication trees disjoint prevents +replacement of the project and prevents a prior bundle from becoming source +input to the next export. Exported file paths must be relative normal +components, and one path cannot carry conflicting bytes or media types. + +This is directory-level local publication safety, not a remote deployment +transaction or crash-consistent filesystem protocol. + +## 10. Provider and publisher boundaries + +Listenerless means the exported artifact needs no dedicated Uhura process. It +still needs an ordinary Web origin that provides: + +- the declared mount; +- correct content types, including Wasm and extensionless JSON; and +- the declared history fallback. + +Export packages the provider module admitted by the project. It does not make +an arbitrary provider offline. A provider may use browser-local state, captured +Uhura assets, a same-origin application API, or a remote authority. Export does +not rewrite those choices, synthesize a backend, or weaken provider admission. + +## 11. Non-goals + +This decision does not introduce: + +- browser parsing, checking, source discovery, or source editing; +- live source watching, incremental publication, or runtime migration; +- server-side rendering, hydration, or a service worker; +- persistence policy or multi-tab coordination; +- an offline guarantee for arbitrary providers; +- provider-specific authority behavior; +- hosting-vendor configuration or deployment; +- a relocatable artifact after export; +- a second renderer or static Canvas; or +- a promise that every future native-host capability must be exportable. + +Archive formats, signing, delta publication, and deployment adapters are +separate decisions. + +## 12. Conformance + +The implemented conformance surface proves: + +- the packaged CLI exports without a source checkout or frontend toolchain; +- live and export Web distributions are located independently; +- one template materializes at root or a nested canonical mount; +- malformed raw and encoded mount paths are rejected; +- static Editor and Play do not create event streams; +- stale Editor and retained last-good Play states cannot be exported; +- outside-mount links and programmatic routes remain outside Uhura ownership; +- required Web, Editor, Play, Wasm, and configured font bytes are present; +- the manifest inventory matches every emitted payload; +- a rejected replacement leaves the prior export intact; and +- the publisher contract describes history fallback without selecting a host. + +Embedding one output in a containing site is useful proof of this contract, +not an additional Uhura feature or dependency. diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md index 8ae659d..b594a3a 100644 --- a/docs/rfcs/README.md +++ b/docs/rfcs/README.md @@ -21,6 +21,7 @@ decision changes, preserve the earlier RFC and add explicit `Supersedes` and | [0003](0003-source-comments-docs-and-annotations.md) | Source comments, declaration docs, and markup annotations | Accepted | | [0004](0004-standalone-machine-core-and-source-composition.md) | Standalone machine core and source composition | Accepted | | [0005](0005-web-application-topology-and-ui-composition.md) | Web application topology and pure UI composition | Accepted | +| [0006](0006-immutable-web-export.md) | Immutable Web export | Accepted | RFC numbers are local to the Uhura project, zero-padded, and never reused after a proposal has been shared. The status inside an RFC is authoritative. diff --git a/scripts/package.sh b/scripts/package.sh index 045354b..ff335ee 100755 --- a/scripts/package.sh +++ b/scripts/package.sh @@ -63,6 +63,9 @@ esac required_files=( "$TARGET_DIR/release/uhura" "$ROOT/web/dist/index.html" + "$ROOT/web/dist/uhura-web-build.json" + "$ROOT/web/dist-export/index.html" + "$ROOT/web/dist-export/uhura-web-build.json" "$ROOT/crates/uhura-wasm/pkg/web/uhura_wasm.js" "$ROOT/crates/uhura-wasm/pkg/web/uhura_wasm_bg.wasm" ) @@ -84,9 +87,11 @@ trap cleanup EXIT mkdir -p \ "$STAGING/bin" \ "$STAGING/share/uhura/web" \ + "$STAGING/share/uhura/web-export" \ "$STAGING/share/uhura/wasm" install -m 755 "$TARGET_DIR/release/uhura" "$STAGING/bin/uhura" cp -R "$ROOT/web/dist/." "$STAGING/share/uhura/web/" +cp -R "$ROOT/web/dist-export/." "$STAGING/share/uhura/web-export/" cp -R "$ROOT/crates/uhura-wasm/pkg/web/." "$STAGING/share/uhura/wasm/" # Replace only after the full package has been assembled. The destination was @@ -97,3 +102,4 @@ STAGING="" echo "Uhura package: $OUT" echo "Run: $OUT/bin/uhura editor " +echo "Export: $OUT/bin/uhura export --out " diff --git a/web/README.md b/web/README.md index b05af0f..b601a6e 100644 --- a/web/README.md +++ b/web/README.md @@ -55,9 +55,10 @@ Spock provider. It is independent of the application dev server. ## Build and runtime contract -`corepack pnpm build` creates two generated products: +`corepack pnpm build` creates three generated products: -- `dist/`: one Vite application build for both Editor and Play; +- `dist/`: the origin-root live build used by native Editor and Play; +- `dist-export/`: the relative-chunk template materialized by `uhura export`; - `../examples/instagram/client/providers/dist/spock.js`: the configured Instagram Play provider. @@ -74,6 +75,7 @@ browser adapter; a configured provider module supplies typed application adapters through `createUhuraAdapters(config, host)`. Deliveries return through a deferred FIFO bridge and cannot synchronously re-enter a reaction. Node and Vite are build-time dependencies only. -`../scripts/package.sh` builds the application, provider, Wasm, and release -binary, then places the runtime web and Wasm assets beside the executable under -`dist/uhura/` (or a supplied output directory). +`../scripts/package.sh` builds both application profiles, the provider, Wasm, +and the release binary. It packages live and export Web distributions +separately beside the executable. `uhura export` configures the export template +for a canonical mount without invoking Node or Vite at command runtime. diff --git a/web/package.json b/web/package.json index e61ff3b..35fb5e1 100644 --- a/web/package.json +++ b/web/package.json @@ -12,8 +12,9 @@ "dev": "vite --config vite.config.ts", "dev:provider": "vite build --watch --config vite.provider.config.ts", "typecheck": "tsc -b", - "build": "pnpm run build:app && pnpm run build:provider", + "build": "pnpm run build:app && pnpm run build:export && pnpm run build:provider", "build:app": "vite build --config vite.config.ts", + "build:export": "vite build --config vite.config.ts --mode export", "build:provider": "vite build --config vite.provider.config.ts", "lint": "pnpm run lint:web && pnpm run lint:provider", "lint:web": "oxlint --deny-warnings src *.config.ts", diff --git a/web/src/app/host.test.ts b/web/src/app/host.test.ts new file mode 100644 index 0000000..c3d3ad7 --- /dev/null +++ b/web/src/app/host.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; + +import { + decodeHostConfig, + escapeHtmlAttribute, + normalizeHostBase, + normalizeMountPath, + normalizePlayEntry, + prefixHostPath, + rebasePlayAsset, + rebasePlayAssetForHost, + resolvePlayEntry, + stripHostPath, +} from "./host.js"; + +describe("mounted host paths", () => { + it("keeps the native root topology unchanged", () => { + expect(normalizeHostBase("/")).toBe(""); + expect(normalizeMountPath("/")).toBe("/"); + expect(prefixHostPath("/", "/play")).toBe("/play"); + expect(stripHostPath("/", "/search")).toBe("/search"); + }); + + it("mounts Editor, Play, APIs, and application routes below one prefix", () => { + expect(normalizeHostBase("/demo/")).toBe("/demo"); + expect(prefixHostPath("/demo/", "/")).toBe("/demo/"); + expect(prefixHostPath("/demo/", "/play")).toBe("/demo/play"); + expect(prefixHostPath("/demo/", "/api/play/ir.json")) + .toBe("/demo/api/play/ir.json"); + expect(stripHostPath("/demo/", "/demo/profile/mira")) + .toBe("/profile/mira"); + expect(stripHostPath("/demo/", "/docs/")).toBeNull(); + }); + + it("canonicalizes encoded paths before they become host identity", () => { + expect(normalizeMountPath("/space%20name")).toBe("/space%20name/"); + expect(normalizeMountPath("/%eb%8d%b0%eb%aa%a8/")) + .toBe("/%EB%8D%B0%EB%AA%A8/"); + expect(normalizeMountPath("/%41%3a/")).toBe("/A%3A/"); + expect(normalizePlayEntry("/orders/%e2%82%ac?step=%69tems")) + .toBe("/orders/%E2%82%AC?step=items"); + expect(normalizePlayEntry("/search?q=a%26b%3dc")) + .toBe("/search?q=a%26b%3Dc"); + expect(normalizePlayEntry( + "/orders/return%2fwith%2fslash?step=review%2fconfirm%2b", + )).toBe( + "/orders/return%2Fwith%2Fslash?step=review%2Fconfirm%2B", + ); + expect(normalizePlayEntry("/orders/100?note=%27ok%27#sum%6dary")) + .toBe("/orders/100?note=%27ok%27#summary"); + expect(normalizePlayEntry("/author's")).toBe("/author's"); + + const stableEntry = normalizePlayEntry( + "/orders/return%2fwith%2fslash?step=review%2fconfirm%2b#details", + ); + const roundTripped = new URL(stableEntry, "https://example.test"); + expect( + `${roundTripped.pathname}${roundTripped.search}${roundTripped.hash}`, + ).toBe(stableEntry); + + for (const mount of [ + "/", + "/space%20name/", + "/%EB%8D%B0%EB%AA%A8/", + "/research&proof/", + ]) { + expect(new URL(mount, "https://example.test").pathname).toBe(mount); + const route = prefixHostPath(mount, "/orders/100"); + expect(stripHostPath(mount, new URL(route, "https://example.test").pathname)) + .toBe("/orders/100"); + } + }); + + it("pins a mounted export to an application-owned Play entry", () => { + expect(resolvePlayEntry("/", undefined)).toBe("/play"); + expect(resolvePlayEntry("/demo/", "/orders/100?step=items")) + .toBe("/demo/orders/100?step=items"); + expect(() => resolvePlayEntry("/demo/", "https://example.com")) + .toThrow(/origin-local path/u); + for (const reserved of [ + "/", + "/_uhura/editor", + "/api", + "/api/play/config.json", + "/assets", + "/assets/app.js", + ]) { + expect(() => resolvePlayEntry("/demo/", reserved)) + .toThrow(/select the Play surface/u); + } + }); + + it("does not reinterpret provider-owned site API URLs as Uhura assets", () => { + expect(rebasePlayAsset("/api/site/avatar/1")).toBe("/api/site/avatar/1"); + expect(rebasePlayAsset("/api/play/assets/poster%2fone.jpg")) + .toBe("/api/play/assets/poster%2Fone.jpg"); + }); + + it("keeps internal resource identities inside the declared mount", () => { + expect(prefixHostPath( + "/demo/", + "/api/play/assets/poster%2fone.jpg", + )).toBe("/demo/api/play/assets/poster%2Fone.jpg"); + for (const resource of [ + "/../outside", + "/%2e%2e/outside", + "/api/play/assets/poster%2F..%2Foutside", + "/api/play/assets/%2Foutside", + ]) { + expect(() => prefixHostPath("/demo/", resource)) + .toThrow(/unsafe path segment/u); + } + }); + + it("uses ordinary hierarchy separators for captured files on static hosts", () => { + expect(rebasePlayAssetForHost( + "/api/play/assets/gallery%2fsummer%20day.jpg", + "/demo/", + true, + )).toBe("/demo/api/play/assets/gallery/summer%20day.jpg"); + expect(rebasePlayAssetForHost( + "/api/play/assets/gallery%2fsummer%20day.jpg?download=%2F", + "/demo/", + true, + )).toBe("/demo/api/play/assets/gallery/summer%20day.jpg?download=%2F"); + expect(rebasePlayAssetForHost( + "/api/play/assets/gallery%2fsummer%20day.jpg", + "/demo/", + false, + )).toBe("/demo/api/play/assets/gallery%2Fsummer%20day.jpg"); + }); + + it.each([ + "demo", + "//example.com/", + "/demo//", + "/./", + "/../", + "/%2e%2e/", + "/a%2fb/", + "/a%5cb/", + "/demo/?query", + "/demo/#fragment", + "/demo\\", + "/demo space/", + "/데모/", + "/demo\"/", + "/demo`/", + "/%00/", + "/%7f/", + "/%c2%85/", + "/%ff/", + ])("rejects unsafe mount path %s", (mountPath) => { + expect(() => normalizeMountPath(mountPath)).toThrow(/Uhura mount path/u); + }); + + it.each([ + "/orders/", + "/orders/$identity", + "/play?query", + "/play?=value", + "/play?state=open&&step=review", + "/play?state=open=again", + "/play?unsafe=+", + "/play?unsafe='", + "/play?unsafe=\u{7f}", + "/play?unsafe=%C2%85", + "/play?unsafe=raw space", + "/play#unsafe#fragment", + ])("rejects unsafe Play entry %s", (playEntry) => { + expect(() => normalizePlayEntry(playEntry)).toThrow(/Uhura Play entry/u); + }); + + it("decodes one explicit runtime host contract", () => { + expect(decodeHostConfig(JSON.stringify({ + protocol: "uhura-host-config/0", + mountPath: "/products/%75hura/", + mode: "static", + playEntry: "/orders/%31%30%30?step=%69tems#summary", + }))).toEqual({ + protocol: "uhura-host-config/0", + mountPath: "/products/uhura/", + mode: "static", + playEntry: "/orders/100?step=items#summary", + }); + }); + + it("rejects invalid runtime config rather than inferring a host mode", () => { + expect(() => decodeHostConfig("{}")).toThrow(/unsupported shape/u); + expect(() => decodeHostConfig(JSON.stringify({ + protocol: "uhura-host-config/0", + mountPath: "/demo/", + mode: "maybe", + playEntry: "/play", + }))).toThrow(/unsupported shape/u); + }); + + it("escapes configured routes before they enter host-owned markup", () => { + expect(escapeHtmlAttribute("/demo/?next=\"<&")) + .toBe("/demo/?next="<&"); + }); +}); diff --git a/web/src/app/host.ts b/web/src/app/host.ts new file mode 100644 index 0000000..2dcee4f --- /dev/null +++ b/web/src/app/host.ts @@ -0,0 +1,488 @@ +/** Browser-host topology selected by the document that boots Uhura. */ + +export const UHURA_HOST_CONFIG_PROTOCOL = "uhura-host-config/0" as const; + +export interface HostConfig { + protocol: typeof UHURA_HOST_CONFIG_PROTOCOL; + mountPath: string; + mode: "live" | "static"; + /** Origin-local application path, before the mount prefix is applied. */ + playEntry: string; +} + +const DEFAULT_HOST_CONFIG: HostConfig = { + protocol: UHURA_HOST_CONFIG_PROTOCOL, + mountPath: "/", + mode: "live", + playEntry: "/play", +}; + +interface JsonObject { + [key: string]: unknown; +} + +const asObject = (value: unknown): JsonObject | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? value as JsonObject + : null; + +const splitPathSuffix = ( + value: string, +): { pathname: string; suffix: string } => { + const query = value.indexOf("?"); + const fragment = value.indexOf("#"); + const boundary = + query === -1 + ? fragment + : fragment === -1 + ? query + : Math.min(query, fragment); + return boundary === -1 + ? { pathname: value, suffix: "" } + : { pathname: value.slice(0, boundary), suffix: value.slice(boundary) }; +}; + +const isAsciiAlphaNumeric = (byte: number): boolean => + (byte >= 0x30 && byte <= 0x39) + || (byte >= 0x41 && byte <= 0x5a) + || (byte >= 0x61 && byte <= 0x7a); + +const isPathSegmentCharacter = (byte: number): boolean => + isAsciiAlphaNumeric(byte) + || "-._~!$&'()*+,;=:@".includes(String.fromCharCode(byte)); + +const isUrlSuffixCharacter = (byte: number): boolean => + isAsciiAlphaNumeric(byte) + || "-._~!$&()*+,;=:@/?".includes(String.fromCharCode(byte)); + +const isUnreserved = (byte: number): boolean => + isAsciiAlphaNumeric(byte) + || "-._~".includes(String.fromCharCode(byte)); + +const isRoutePathComponentCharacter = (byte: number): boolean => + isAsciiAlphaNumeric(byte) + || "-._!~*'()".includes(String.fromCharCode(byte)); + +const isRouteQueryComponentCharacter = (byte: number): boolean => + isRoutePathComponentCharacter(byte) && byte !== 0x27; + +const hexValue = (byte: number): number | null => { + if (byte >= 0x30 && byte <= 0x39) return byte - 0x30; + if (byte >= 0x41 && byte <= 0x46) return byte - 0x41 + 10; + if (byte >= 0x61 && byte <= 0x66) return byte - 0x61 + 10; + return null; +}; + +const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); +const utf8Encoder = new TextEncoder(); + +const normalizeUrlComponent = ( + value: string, + label: string, + rawAllowed: (byte: number) => boolean, + escapedRawAllowed: (byte: number) => boolean = isUnreserved, +): { canonical: string; decoded: string } => { + const source = utf8Encoder.encode(value); + const decodedBytes: number[] = []; + const canonical: string[] = []; + for (let index = 0; index < source.length;) { + const byte = source[index]!; + if (byte !== 0x25) { + if (byte > 0x7f || !rawAllowed(byte)) { + throw new TypeError( + `${label} contains a character that must be percent-encoded`, + ); + } + decodedBytes.push(byte); + canonical.push(String.fromCharCode(byte)); + index += 1; + continue; + } + const high = source[index + 1] === undefined + ? null + : hexValue(source[index + 1]!); + const low = source[index + 2] === undefined + ? null + : hexValue(source[index + 2]!); + if (high === null || low === null) { + throw new TypeError(`${label} contains an invalid percent escape`); + } + const decodedByte = (high << 4) | low; + decodedBytes.push(decodedByte); + canonical.push( + escapedRawAllowed(decodedByte) + ? String.fromCharCode(decodedByte) + : `%${decodedByte.toString(16).toUpperCase().padStart(2, "0")}`, + ); + index += 3; + } + + let decoded: string; + try { + decoded = utf8Decoder.decode(Uint8Array.from(decodedBytes)); + } catch { + throw new TypeError(`${label} contains an invalid UTF-8 escape`); + } + if ([...decoded].some((character) => { + const point = character.codePointAt(0)!; + return point <= 0x1f || (point >= 0x7f && point <= 0x9f); + })) { + throw new TypeError(`${label} contains an unsafe control character`); + } + return { canonical: canonical.join(""), decoded }; +}; + +const normalizePathSegment = ( + segment: string, + label: string, + allowEncodedSlashes: boolean, +): string => { + const { canonical, decoded } = normalizeUrlComponent( + segment, + label, + isPathSegmentCharacter, + ); + if (decoded === "." || decoded === ".." || decoded.includes("\\")) { + throw new TypeError(`${label} contains an unsafe path segment`); + } + if ( + decoded.includes("/") + && ( + !allowEncodedSlashes + || decoded.split("/").some((part) => + part === "" || part === "." || part === ".." + ) + ) + ) { + throw new TypeError(`${label} contains an unsafe path segment`); + } + return canonical; +}; + +const normalizeOriginPath = ( + pathname: string, + label: string, + directory: boolean, + allowEncodedSlashes = false, +): string => { + if ( + !pathname.startsWith("/") + || pathname.startsWith("//") + || pathname.includes("\\") + || pathname.includes("?") + || pathname.includes("#") + || [...pathname].some((character) => character.codePointAt(0)! < 0x20) + ) { + throw new TypeError(`${label} must be an origin-local path`); + } + if (directory && !pathname.endsWith("/")) { + throw new TypeError(`${label} must end with /`); + } + if (pathname === "/") return "/"; + const segments = pathname.split("/"); + const body = segments.slice(1, pathname.endsWith("/") ? -1 : undefined); + if (body.some((segment) => segment === "")) { + throw new TypeError(`${label} contains an empty path segment`); + } + const normalized = body.map((segment) => + normalizePathSegment(segment, label, allowEncodedSlashes) + ); + return `/${normalized.join("/")}${pathname.endsWith("/") ? "/" : ""}`; +}; + +const normalizePlayEntryPath = (pathname: string, label: string): string => { + if ( + !pathname.startsWith("/") + || pathname.startsWith("//") + || pathname.includes("\\") + || pathname.includes("?") + || pathname.includes("#") + || [...pathname].some((character) => character.codePointAt(0)! < 0x20) + ) { + throw new TypeError(`${label} must be an origin-local path`); + } + if (pathname === "/") return "/"; + if (pathname.endsWith("/") && pathname !== "/play/") { + throw new TypeError(`${label} contains an empty path segment`); + } + const body = pathname.slice(1, pathname.endsWith("/") ? -1 : undefined); + const segments = body.split("/"); + if (segments.some((segment) => segment === "")) { + throw new TypeError(`${label} contains an empty path segment`); + } + const normalized = segments.map((segment) => { + const { canonical, decoded } = normalizeUrlComponent( + segment, + label, + isRoutePathComponentCharacter, + isRoutePathComponentCharacter, + ); + if (decoded === "." || decoded === "..") { + throw new TypeError(`${label} contains a non-canonical route component`); + } + return canonical; + }); + return `/${normalized.join("/")}${pathname.endsWith("/") ? "/" : ""}`; +}; + +/** Return the canonical public mount spelling: `/` or `/segment/.../`. */ +export const normalizeMountPath = (mountPath: string): string => { + if (mountPath !== mountPath.trim() || mountPath === "") { + throw new TypeError("Uhura mount path must be an origin-local path"); + } + const normalized = + mountPath === "/" || mountPath.endsWith("/") + ? mountPath + : `${mountPath}/`; + return normalizeOriginPath(normalized, "Uhura mount path", true); +}; + +/** Internal prefix spelling, without the mount's trailing slash. */ +export const normalizeHostBase = (base: string): string => { + if (base === "") return ""; + const mountPath = normalizeMountPath(base); + return mountPath === "/" ? "" : mountPath.slice(0, -1); +}; + +export const prefixHostPath = (base: string, path: string): string => { + const { pathname, suffix } = splitPathSuffix(path); + const hostPath = normalizeOriginPath( + pathname, + "Uhura host path", + false, + true, + ); + const hostSuffix = normalizeUrlSuffix(suffix, "Uhura host path"); + const normalized = normalizeHostBase(base); + if (hostPath === "/") { + return `${normalized === "" ? "/" : `${normalized}/`}${hostSuffix}`; + } + return `${normalized}${hostPath}${hostSuffix}`; +}; + +const normalizeUrlSuffix = (suffix: string, label: string): string => { + if (suffix === "") return ""; + if (suffix.startsWith("?")) { + const fragmentIndex = suffix.indexOf("#", 1); + const query = fragmentIndex === -1 + ? suffix.slice(1) + : suffix.slice(1, fragmentIndex); + const fragment = fragmentIndex === -1 + ? null + : suffix.slice(fragmentIndex + 1); + const normalizedQuery = normalizeUrlComponent( + query, + label, + isUrlSuffixCharacter, + ).canonical; + if (fragment === null) return `?${normalizedQuery}`; + const normalizedFragment = normalizeUrlComponent( + fragment, + label, + isUrlSuffixCharacter, + ).canonical; + return `?${normalizedQuery}#${normalizedFragment}`; + } + if (suffix.startsWith("#")) { + return `#${ + normalizeUrlComponent( + suffix.slice(1), + label, + isUrlSuffixCharacter, + ).canonical + }`; + } + throw new TypeError(`${label} has an invalid URL suffix`); +}; + +const normalizeRouteQueryComponent = ( + value: string, + label: string, +): string => + normalizeUrlComponent( + value, + label, + isRouteQueryComponentCharacter, + isRouteQueryComponentCharacter, + ).canonical; + +const normalizeRouteQuery = (query: string, label: string): string => { + if (query === "") return ""; + const normalized = query.split("&").map((pair) => { + if (pair === "") { + throw new TypeError(`${label} query contains an empty pair`); + } + const separator = pair.indexOf("="); + if ( + separator <= 0 + || pair.indexOf("=", separator + 1) !== -1 + ) { + throw new TypeError(`${label} query contains a malformed pair`); + } + const key = normalizeRouteQueryComponent(pair.slice(0, separator), label); + const value = normalizeRouteQueryComponent( + pair.slice(separator + 1), + label, + ); + return `${key}=${value}`; + }); + return `?${normalized.join("&")}`; +}; + +const normalizePlayEntrySuffix = (suffix: string, label: string): string => { + if (suffix === "") return ""; + if (suffix.startsWith("?")) { + const fragmentIndex = suffix.indexOf("#", 1); + const query = fragmentIndex === -1 + ? suffix.slice(1) + : suffix.slice(1, fragmentIndex); + const fragment = fragmentIndex === -1 + ? null + : suffix.slice(fragmentIndex + 1); + const normalizedQuery = normalizeRouteQuery(query, label); + if (fragment === null || fragment === "") return normalizedQuery; + const normalizedFragment = normalizeUrlComponent( + fragment, + label, + isUrlSuffixCharacter, + ).canonical; + return normalizedFragment === "" + ? normalizedQuery + : `${normalizedQuery}#${normalizedFragment}`; + } + if (suffix.startsWith("#")) { + const fragment = normalizeUrlComponent( + suffix.slice(1), + label, + isUrlSuffixCharacter, + ).canonical; + return fragment === "" ? "" : `#${fragment}`; + } + throw new TypeError(`${label} has an invalid URL suffix`); +}; + +const playEntryPathIsReserved = (pathname: string): boolean => + pathname === "/" + || pathname === "/_uhura/editor" + || pathname === "/_uhura/editor/" + || pathname === "/api" + || pathname.startsWith("/api/") + || pathname === "/assets" + || pathname.startsWith("/assets/"); + +export const normalizePlayEntry = (configured: string | undefined): string => { + const entry = configured === undefined ? "/play" : configured; + if (entry === "" || entry !== entry.trim()) { + throw new TypeError("Uhura Play entry must be an origin-local path"); + } + const { pathname, suffix } = splitPathSuffix(entry); + const normalizedPath = normalizePlayEntryPath( + pathname, + "Uhura Play entry", + ); + if (playEntryPathIsReserved(normalizedPath)) { + throw new TypeError("Uhura Play entry must select the Play surface"); + } + return `${ + normalizedPath + }${normalizePlayEntrySuffix(suffix, "Uhura Play entry")}`; +}; + +export const resolvePlayEntry = ( + base: string, + configured: string | undefined, +): string => { + const entry = normalizePlayEntry(configured); + return prefixHostPath(base, entry); +}; + +export const stripHostPath = ( + base: string, + pathname: string, +): string | null => { + const normalized = normalizeHostBase(base); + if (normalized === "") return pathname; + if (pathname === normalized || pathname === `${normalized}/`) return "/"; + return pathname.startsWith(`${normalized}/`) + ? pathname.slice(normalized.length) + : null; +}; + +export const decodeHostConfig = (serialized: string): HostConfig => { + let decoded: unknown; + try { + decoded = JSON.parse(serialized); + } catch (error) { + throw new TypeError(`Uhura host config is not valid JSON: ${String(error)}`); + } + const config = asObject(decoded); + if ( + config === null + || config["protocol"] !== UHURA_HOST_CONFIG_PROTOCOL + || typeof config["mountPath"] !== "string" + || (config["mode"] !== "live" && config["mode"] !== "static") + || typeof config["playEntry"] !== "string" + ) { + throw new TypeError("Uhura host config has an unsupported shape"); + } + const mountPath = normalizeMountPath(config["mountPath"]); + const playEntry = normalizePlayEntry(config["playEntry"]); + return { + protocol: UHURA_HOST_CONFIG_PROTOCOL, + mountPath, + mode: config["mode"], + playEntry, + }; +}; + +const documentHostConfig = (): HostConfig => { + if (typeof document === "undefined") return DEFAULT_HOST_CONFIG; + const config = document.getElementById("uhura-host-config"); + if (config === null) { + throw new TypeError("Uhura document is missing #uhura-host-config"); + } + return decodeHostConfig(config.textContent ?? ""); +}; + +export const UHURA_HOST_CONFIG = documentHostConfig(); +export const UHURA_HOST_BASE = normalizeHostBase( + UHURA_HOST_CONFIG.mountPath, +); +export const UHURA_STATIC_HOST = UHURA_HOST_CONFIG.mode === "static"; +export const UHURA_PLAY_ENTRY = resolvePlayEntry( + UHURA_HOST_CONFIG.mountPath, + UHURA_HOST_CONFIG.playEntry, +); + +export const escapeHtmlAttribute = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll("\"", """) + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +export const hostPath = (path: string): string => + prefixHostPath(UHURA_HOST_BASE, path); + +/** Rebase native-host absolute resources into a mounted host prefix. */ +export const rebaseHostResource = (resource: string): string => { + if (!resource.startsWith("/api/")) return resource; + return prefixHostPath(UHURA_HOST_BASE, resource); +}; + +export const rebasePlayAssetForHost = ( + resource: string, + base: string, + staticHost: boolean, +): string => { + if (!resource.startsWith("/api/play/assets/")) return resource; + const rebased = prefixHostPath(base, resource); + if (!staticHost) return rebased; + const { pathname, suffix } = splitPathSuffix(rebased); + return `${pathname.replaceAll("%2F", "/")}${suffix}`; +}; + +/** Rebase only the transport namespace owned by Uhura's asset publisher. */ +export const rebasePlayAsset = (resource: string): string => + rebasePlayAssetForHost(resource, UHURA_HOST_BASE, UHURA_STATIC_HOST); diff --git a/web/src/app/index.html b/web/src/app/index.html index b0db533..b7b45c4 100644 --- a/web/src/app/index.html +++ b/web/src/app/index.html @@ -8,6 +8,9 @@
+ diff --git a/web/src/app/router.test.ts b/web/src/app/router.test.ts index 639fb71..80c833c 100644 --- a/web/src/app/router.test.ts +++ b/web/src/app/router.test.ts @@ -1,11 +1,13 @@ import assert from "node:assert/strict"; -import { test } from "vitest"; +import { test, vi } from "vitest"; import type { SurfaceLoader, SurfaceMount } from "./router.js"; import { createRouteRenderer, EDITOR_PATH, + routableHostUrl, routeFor, + routeForHost, } from "./router.js"; const deferred = (): { @@ -65,6 +67,50 @@ test("only reserved editor entry points select Editor", () => { assert.equal(routeFor("/_uhura/editor/preferences").surface, "play"); }); +test("a mounted host owns only its segment-scoped browser paths", () => { + assert.equal(routeForHost("/demo/", "/demo/")?.surface, "editor"); + assert.equal( + routeForHost("/demo/", "/demo/_uhura/editor")?.surface, + "editor", + ); + assert.equal(routeForHost("/demo/", "/demo/play")?.surface, "play"); + assert.equal( + routeForHost("/demo/", "/demo/profile/mira")?.surface, + "play", + ); + + assert.equal(routeForHost("/demo/", "/docs"), null); + assert.equal(routeForHost("/demo/", "/demolition"), null); +}); + +test("same-origin URLs are routable only inside the mounted host", () => { + const origin = "https://example.test"; + assert.equal( + routableHostUrl("/demo/", origin, new URL(`${origin}/demo/play`)), + true, + ); + assert.equal( + routableHostUrl("/demo/", origin, new URL(`${origin}/demo`)), + true, + ); + assert.equal( + routableHostUrl("/demo/", origin, new URL(`${origin}/docs`)), + false, + ); + assert.equal( + routableHostUrl("/demo/", origin, new URL(`${origin}/demolition`)), + false, + ); + assert.equal( + routableHostUrl( + "/demo/", + origin, + new URL("https://outside.test/demo/play"), + ), + false, + ); +}); + test("real application locations keep one running Play surface", async () => { let playLoads = 0; let playMounts = 0; @@ -101,3 +147,53 @@ test("real application locations keep one running Play surface", async () => { await renderer.render(EDITOR_PATH); assert.equal(playDisposals, 1); }); + +test("a mounted router ignores initial browser locations outside its mount", async () => { + vi.resetModules(); + vi.stubGlobal("document", { + getElementById(): { textContent: string } { + return { + textContent: JSON.stringify({ + protocol: "uhura-host-config/0", + mountPath: "/demo/", + mode: "static", + playEntry: "/play", + }), + }; + }, + documentElement: { dataset: {} }, + addEventListener(): void {}, + }); + vi.stubGlobal("window", { addEventListener(): void {} }); + vi.stubGlobal("location", { + href: "https://example.test/docs", + origin: "https://example.test", + pathname: "/docs", + search: "", + hash: "", + }); + vi.stubGlobal("history", { + replaceState(): void {}, + pushState(): void {}, + }); + + try { + const { createRouter: createMountedRouter } = await import("./router.js"); + let loads = 0; + createMountedRouter({ + root: { replaceChildren(): void {} } as unknown as HTMLElement, + loadEditor: async () => { + loads += 1; + return () => undefined; + }, + loadPlay: async () => { + loads += 1; + return () => undefined; + }, + }).start(); + await Promise.resolve(); + assert.equal(loads, 0); + } finally { + vi.unstubAllGlobals(); + } +}); diff --git a/web/src/app/router.ts b/web/src/app/router.ts index 01a2f43..9877103 100644 --- a/web/src/app/router.ts +++ b/web/src/app/router.ts @@ -1,3 +1,5 @@ +import { hostPath, stripHostPath, UHURA_HOST_BASE } from "./host.js"; + export type SurfaceDispose = () => void; export type SurfaceMount = ( root: HTMLElement, @@ -53,12 +55,31 @@ export interface RouteRenderer { render(pathname: string): Promise; } -export const EDITOR_PATH = "/_uhura/editor"; +export const EDITOR_PATH = hostPath("/_uhura/editor"); -const editorPath = (pathname: string): boolean => +const editorHostPath = (pathname: string): boolean => pathname === "/" - || pathname === EDITOR_PATH - || pathname === `${EDITOR_PATH}/`; + || pathname === "/_uhura/editor" + || pathname === "/_uhura/editor/"; + +export const routeForHost = ( + base: string, + pathname: string, +): AppRoute | null => { + const hosted = stripHostPath(base, pathname); + if (hosted === null) return null; + return { + pathname, + surface: editorHostPath(hosted) ? "editor" : "play", + }; +}; + +export const routableHostUrl = ( + base: string, + origin: string, + url: URL, +): boolean => + url.origin === origin && stripHostPath(base, url.pathname) !== null; /** * `/` remains the friendly Editor entry. The explicit reserved route makes @@ -66,10 +87,15 @@ const editorPath = (pathname: string): boolean => * `/play` is the compatibility Play entry; every other pathname is an actual * application location and therefore also belongs to Play. */ -export const routeFor = (pathname: string): AppRoute => ({ - pathname, - surface: editorPath(pathname) ? "editor" : "play", -}); +export const routeFor = (pathname: string): AppRoute => { + const route = routeForHost(UHURA_HOST_BASE, pathname); + if (route === null) { + throw new Error( + `browser path ${JSON.stringify(pathname)} is outside the Uhura host`, + ); + } + return route; +}; interface RoutedAnchor { url: URL; @@ -82,7 +108,7 @@ const routedAnchor = (target: EventTarget | null): RoutedAnchor | null => { if (anchor.target && anchor.target !== "_self") return null; if (anchor.hasAttribute("download")) return null; const url = new URL(anchor.href, location.href); - if (url.origin !== location.origin) return null; + if (!routableHostUrl(UHURA_HOST_BASE, location.origin, url)) return null; return { url }; }; @@ -141,12 +167,14 @@ export function createRouter(options: RouterOptions): AppRouter { cause: NavigationCause, ): Promise => { const sequence = ++locationSequence; + const route = routeForHost(UHURA_HOST_BASE, url.pathname); + if (route === null) return; const committed = await renderer.render(url.pathname); if (!committed || sequence !== locationSequence) return; options.locationChanged?.({ cause, location: browserLocation(url), - route: routeFor(url.pathname), + route, }); }; @@ -158,6 +186,11 @@ export function createRouter(options: RouterOptions): AppRouter { if (url.origin !== location.origin) { throw new Error(`cannot route a different origin: ${url.origin}`); } + if (stripHostPath(UHURA_HOST_BASE, url.pathname) === null) { + throw new Error( + `cannot route outside the Uhura host: ${url.pathname}`, + ); + } const href = `${url.pathname}${url.search}${url.hash}`; const current = `${location.pathname}${location.search}${location.hash}`; if (replace) { diff --git a/web/src/editor/editor.ts b/web/src/editor/editor.ts index a2b30ae..7459784 100644 --- a/web/src/editor/editor.ts +++ b/web/src/editor/editor.ts @@ -80,10 +80,17 @@ import { editorIdentifierLabel, editorPreviewLabels, } from "./display-labels.js"; - -const EDITOR_STATE_PATH = "/api/editor/state"; -const EDITOR_ICON_FONTS_PATH = "/api/editor/icon-fonts.json"; -const EDITOR_EVENTS_PATH = "/api/editor/events"; +import { + escapeHtmlAttribute, + hostPath, + rebaseHostResource, + UHURA_PLAY_ENTRY, + UHURA_STATIC_HOST, +} from "../app/host.js"; + +const EDITOR_STATE_PATH = hostPath("/api/editor/state"); +const EDITOR_ICON_FONTS_PATH = hostPath("/api/editor/icon-fonts.json"); +const EDITOR_EVENTS_PATH = hostPath("/api/editor/events"); const UI_VISIBLE_KEY = "uhura.editor.ui-visible"; const MIN_SCALE = 0.02; const MAX_SCALE = 3; @@ -256,7 +263,7 @@ const SHELL_HTML = `