mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
611 lines
28 KiB
Swift
611 lines
28 KiB
Swift
import Foundation
|
||
|
||
// Everything the shell decides before WebKit is involved: the session id it will accept, the
|
||
// requests it will answer, the map it builds from a manifest, and the policy header. Compiled and
|
||
// run without a device:
|
||
//
|
||
// swiftc -O -o /tmp/mobile-web-shell-checks \
|
||
// ios/MobileWebShellOrigin.swift ios/MobileWebShellGeneration.swift ios/MobileWebShellCsp.swift \
|
||
// ios/MobileWebShellLoadState.swift ios/MobileWebShellResponseHeaders.swift \
|
||
// ios/MobileWebShellBridge.swift ios/MobileWebShellAppliedProps.swift \
|
||
// ios/MobileWebShellNavigationPolicy.swift \
|
||
// tests/MobileWebShellChecks.swift && /tmp/mobile-web-shell-checks
|
||
@main struct MobileWebShellChecks {
|
||
static let session = "sess-01JN_aZ9"
|
||
|
||
static func parts(
|
||
path: String,
|
||
method: String = "GET",
|
||
hasRangeHeader: Bool = false,
|
||
scheme: String? = MobileWebShellOrigin.scheme,
|
||
host: String? = session,
|
||
port: Int? = nil,
|
||
user: String? = nil,
|
||
query: String? = nil,
|
||
fragment: String? = nil,
|
||
urlByteCount: Int = 64
|
||
) -> MobileWebShellRequestParts {
|
||
MobileWebShellRequestParts(
|
||
method: method,
|
||
hasRangeHeader: hasRangeHeader,
|
||
scheme: scheme,
|
||
host: host,
|
||
port: port,
|
||
user: user,
|
||
query: query,
|
||
fragment: fragment,
|
||
percentEncodedPath: path,
|
||
urlByteCount: urlByteCount
|
||
)
|
||
}
|
||
|
||
static func resolve(_ request: MobileWebShellRequestParts) -> String? {
|
||
MobileWebShellOrigin.resolveRequestPath(request, sessionId: session)
|
||
}
|
||
|
||
static func manifest(
|
||
schemaVersion: Int = 1,
|
||
entrypoint: String = "index.html",
|
||
assets: [[String: Any]] = [
|
||
["path": "index.html", "contentType": "text/html; charset=utf-8"],
|
||
["path": "assets/aa.js", "contentType": "text/javascript; charset=utf-8"],
|
||
["path": "assets/bb.png", "contentType": "image/png"]
|
||
]
|
||
) -> Data {
|
||
let root: [String: Any] = [
|
||
"schemaVersion": schemaVersion,
|
||
"entrypoint": entrypoint,
|
||
"assets": assets
|
||
]
|
||
return try! JSONSerialization.data(withJSONObject: root)
|
||
}
|
||
|
||
static func generation(_ data: Data) -> MobileWebShellGeneration? {
|
||
try? MobileWebShellGeneration.make(
|
||
manifestData: data,
|
||
directory: URL(fileURLWithPath: "/tmp/generation", isDirectory: true)
|
||
)
|
||
}
|
||
|
||
static func checkSessionIds() {
|
||
precondition(MobileWebShellOrigin.isValidSessionId("aZ0-_"))
|
||
precondition(MobileWebShellOrigin.isValidSessionId(String(repeating: "a", count: 128)))
|
||
precondition(!MobileWebShellOrigin.isValidSessionId(String(repeating: "a", count: 129)))
|
||
precondition(!MobileWebShellOrigin.isValidSessionId(""))
|
||
precondition(!MobileWebShellOrigin.isValidSessionId("has space"))
|
||
precondition(!MobileWebShellOrigin.isValidSessionId("dots.are.hosts.too"))
|
||
precondition(!MobileWebShellOrigin.isValidSessionId("sl/ash"))
|
||
// Reject non-ASCII letters/digits that Character accepts; IDNA must not remap the minted origin.
|
||
precondition(!MobileWebShellOrigin.isValidSessionId("sessioñ"))
|
||
precondition(!MobileWebShellOrigin.isValidSessionId("session٣"))
|
||
precondition(MobileWebShellOrigin.documentUrl(sessionId: session)?.absoluteString ==
|
||
"orca-mobile-web://\(session)/")
|
||
precondition(MobileWebShellOrigin.documentUrl(sessionId: "bad host") == nil)
|
||
}
|
||
|
||
static func checkRequestResolution() {
|
||
precondition(resolve(parts(path: "/")) == "/")
|
||
precondition(resolve(parts(path: "")) == "/")
|
||
precondition(resolve(parts(path: "/assets/aa.js")) == "/assets/aa.js")
|
||
// A host a parser canonicalised must still bind to this session.
|
||
precondition(resolve(parts(path: "/", host: session.uppercased())) == "/")
|
||
|
||
precondition(resolve(parts(path: "/", method: "POST")) == nil)
|
||
precondition(resolve(parts(path: "/", method: "HEAD")) == nil)
|
||
precondition(resolve(parts(path: "/", hasRangeHeader: true)) == nil)
|
||
precondition(resolve(parts(path: "/", scheme: "https")) == nil)
|
||
precondition(resolve(parts(path: "/", scheme: nil)) == nil)
|
||
// Unicode folding would serve assets to unminted Kelvin-sign hosts.
|
||
precondition(MobileWebShellOrigin.resolveRequestPath(
|
||
parts(path: "/", host: "\u{212A}ey"),
|
||
sessionId: "key"
|
||
) == nil)
|
||
precondition(MobileWebShellOrigin.resolveRequestPath(
|
||
parts(path: "/", host: "KEY"),
|
||
sessionId: "key"
|
||
) == "/")
|
||
precondition(resolve(parts(path: "/", host: "other-session")) == nil)
|
||
precondition(resolve(parts(path: "/", host: nil)) == nil)
|
||
precondition(resolve(parts(path: "/", port: 443)) == nil)
|
||
precondition(resolve(parts(path: "/", user: "someone")) == nil)
|
||
precondition(resolve(parts(path: "/", query: "v=1")) == nil)
|
||
precondition(resolve(parts(path: "/", fragment: "frag")) == nil)
|
||
precondition(resolve(parts(path: "/assets/%2e%2e/etc")) == nil)
|
||
precondition(resolve(parts(path: "assets/aa.js")) == nil)
|
||
precondition(resolve(parts(path: "/", urlByteCount: 8 * 1024)) == "/")
|
||
precondition(resolve(parts(path: "/", urlByteCount: 8 * 1024 + 1)) == nil)
|
||
precondition(MobileWebShellOrigin.resolveRequestPath(parts(path: "/"), sessionId: "") == nil)
|
||
}
|
||
|
||
static func checkAssetPaths() {
|
||
precondition(MobileWebShellGeneration.isServableAssetPath("index.html"))
|
||
precondition(MobileWebShellGeneration.isServableAssetPath("assets/a-b_c.2.js"))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath(""))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath("/leading"))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath("trailing/"))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath("a//b"))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath("../secret"))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath("assets/../../secret"))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath("assets/./a.js"))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath("back\\slash"))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath("has space.js"))
|
||
precondition(MobileWebShellGeneration.isServableAssetPath(String(repeating: "a", count: 255)))
|
||
precondition(!MobileWebShellGeneration.isServableAssetPath(String(repeating: "a", count: 256)))
|
||
}
|
||
|
||
static func checkContentTypes() {
|
||
precondition(MobileWebShellGeneration.isServableContentType("image/png"))
|
||
precondition(MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8"))
|
||
precondition(MobileWebShellGeneration.isServableContentType("application/manifest+json"))
|
||
precondition(!MobileWebShellGeneration.isServableContentType(""))
|
||
precondition(!MobileWebShellGeneration.isServableContentType("text/html"
|
||
+ "\r\nX-Injected: 1"))
|
||
precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset=utf-8; x=1"))
|
||
precondition(!MobileWebShellGeneration.isServableContentType("TEXT/HTML"))
|
||
// A header value we did not mint character for character is a value we did not check.
|
||
precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset=UTF-8"))
|
||
precondition(!MobileWebShellGeneration.isServableContentType("text"))
|
||
precondition(!MobileWebShellGeneration.isServableContentType("text/html/extra"))
|
||
precondition(!MobileWebShellGeneration.isServableContentType("/html"))
|
||
precondition(!MobileWebShellGeneration.isServableContentType("-text/html"))
|
||
precondition(!MobileWebShellGeneration.isServableContentType("text/html; charset="))
|
||
precondition(!MobileWebShellGeneration.isServableContentType(
|
||
String(repeating: "a", count: 130) + "/b"))
|
||
}
|
||
|
||
static func checkGenerationMap() {
|
||
guard let built = generation(manifest()) else { preconditionFailure("manifest rejected") }
|
||
precondition(built.entries.count == 4)
|
||
precondition(built.entries["/"]?.file.path == "/tmp/generation/index.html")
|
||
precondition(built.entries["/"]?.contentType == "text/html; charset=utf-8")
|
||
// Only / may serve document bytes with CSP; no entrypoint alias.
|
||
precondition(built.entries["/index.html"] == nil)
|
||
precondition(built.entries["/assets/aa.js"]?.contentType == "text/javascript; charset=utf-8")
|
||
precondition(built.entries["/assets/bb.png"]?.file.path == "/tmp/generation/assets/bb.png")
|
||
precondition(built.entries["/manifest.json"]?.contentType == "application/json")
|
||
precondition(built.entries["/assets/cc.js"] == nil)
|
||
precondition(built.entries["/../secret"] == nil)
|
||
|
||
precondition(generation(manifest(schemaVersion: 2)) == nil)
|
||
precondition(generation(manifest(entrypoint: "start.html")) == nil)
|
||
precondition(generation(manifest(assets: [])) == nil)
|
||
// The entrypoint must be one of the assets, or "/" would map to a file nobody declared.
|
||
precondition(generation(manifest(assets: [
|
||
["path": "assets/aa.js", "contentType": "text/javascript; charset=utf-8"]
|
||
])) == nil)
|
||
precondition(generation(manifest(assets: [
|
||
["path": "index.html", "contentType": "text/html; charset=utf-8"],
|
||
["path": "../escape.js", "contentType": "text/javascript; charset=utf-8"]
|
||
])) == nil)
|
||
precondition(generation(manifest(assets: [
|
||
["path": "index.html", "contentType": "text/html; charset=utf-8"],
|
||
["path": "assets/aa.js", "contentType": "text/javascript\r\nX-Injected: 1"]
|
||
])) == nil)
|
||
precondition(generation(manifest(assets: [
|
||
["path": "index.html", "contentType": "text/html; charset=utf-8"],
|
||
["path": 7, "contentType": "text/javascript; charset=utf-8"]
|
||
])) == nil)
|
||
let tooMany = (0..<257).map { index in
|
||
["path": "assets/a\(index).js", "contentType": "text/javascript; charset=utf-8"]
|
||
}
|
||
precondition(generation(manifest(assets: tooMany)) == nil)
|
||
// NSNumber bridges JSON true and 1.0 to Int; neither satisfies the integer-1 contract.
|
||
precondition(generation(Data(#"{"schemaVersion":true,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil)
|
||
precondition(generation(Data(#"{"schemaVersion":1.0,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil)
|
||
precondition(generation(Data(#"{"schemaVersion":1,"entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) != nil)
|
||
precondition(generation(Data(#"{"schemaVersion":"1","entrypoint":"index.html","assets":[{"path":"index.html","contentType":"text/html"}]}"#.utf8)) == nil)
|
||
precondition(generation(Data("not json".utf8)) == nil)
|
||
precondition(generation(Data("[]".utf8)) == nil)
|
||
}
|
||
|
||
static func checkCsp() {
|
||
let header = MobileWebShellCsp.header
|
||
let directives = header.components(separatedBy: "; ")
|
||
precondition(directives.contains("default-src 'none'"))
|
||
precondition(directives.contains("script-src 'self'"))
|
||
// React Native Web injects runtime styles with no nonce; see MobileWebShellCsp.
|
||
precondition(directives.contains("style-src 'self' 'unsafe-inline'"))
|
||
// data: previews and https: icons/srcdoc images need image-only access; see MobileWebShellCsp.
|
||
precondition(directives.contains("img-src 'self' data: blob: https:"))
|
||
precondition(directives.contains("connect-src 'self'"))
|
||
precondition(directives.contains("worker-src 'none'"))
|
||
precondition(directives.contains("frame-src 'none'"))
|
||
precondition(directives.contains("base-uri about:"))
|
||
precondition(directives.contains("form-action 'none'"))
|
||
precondition(directives.contains("frame-ancestors 'none'"))
|
||
// Inline styling must never grant inline script execution.
|
||
precondition(directives.filter { $0.contains("unsafe-inline") } == ["style-src 'self' 'unsafe-inline'"])
|
||
precondition(!header.contains("unsafe-eval"))
|
||
// Only images and bundled fonts can use embedded data URLs.
|
||
precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: blob: https:", "font-src 'self' data:"])
|
||
precondition(directives.filter { $0.contains("blob:") } == ["img-src 'self' data: blob: https:", "media-src blob:"])
|
||
// Admit https: only for images; http: is not a substring and remains forbidden.
|
||
precondition(directives.filter { $0.contains("https:") } == ["img-src 'self' data: blob: https:"])
|
||
precondition(!header.contains("http:"))
|
||
precondition(!header.contains("\r") && !header.contains("\n"))
|
||
}
|
||
|
||
static func checkLoadStateMachine() {
|
||
let failures: [(MobileWebShellFailureReason, String)] = [
|
||
(.generationUnreadable, "generation-unreadable"),
|
||
(.isolationUnavailable, "isolation-unavailable"),
|
||
(.documentLoadFailed, "document-load-failed"),
|
||
(.renderProcessGone, "render-process-gone")
|
||
]
|
||
for (reason, expected) in failures {
|
||
let machine = MobileWebShellLoadStateMachine()
|
||
precondition(machine.failed(reason) == MobileWebShellLoadEmission(state: "failed", reason: expected))
|
||
}
|
||
|
||
// Only the view’s own load raises the flag; commit, failure, renderer death and failed prop application clear it.
|
||
let ownLoad = MobileWebShellLoadStateMachine()
|
||
precondition(!ownLoad.isShellLoad)
|
||
ownLoad.shellLoadStarted()
|
||
precondition(ownLoad.isShellLoad)
|
||
ownLoad.committed()
|
||
precondition(!ownLoad.isShellLoad)
|
||
ownLoad.shellLoadStarted()
|
||
_ = ownLoad.failed(.documentLoadFailed)
|
||
precondition(!ownLoad.isShellLoad)
|
||
ownLoad.reset()
|
||
ownLoad.shellLoadStarted()
|
||
ownLoad.documentEnded()
|
||
precondition(!ownLoad.isShellLoad)
|
||
|
||
let progress = MobileWebShellLoadStateMachine()
|
||
precondition(progress.started()?.state == "loading")
|
||
precondition(progress.started() == nil)
|
||
progress.committed()
|
||
precondition(progress.finished()?.state == "ready")
|
||
precondition(progress.finished() == nil)
|
||
|
||
// replaceState rewrites the URL before finish; readiness must depend on commit state.
|
||
let unseated = MobileWebShellLoadStateMachine()
|
||
_ = unseated.started()
|
||
precondition(unseated.finished() == nil)
|
||
unseated.committed()
|
||
precondition(unseated.finished()?.state == "ready")
|
||
|
||
// A document replaced mid-load: the finish belongs to the one that is already gone.
|
||
let replaced = MobileWebShellLoadStateMachine()
|
||
replaced.committed()
|
||
replaced.documentEnded()
|
||
precondition(replaced.finished() == nil)
|
||
|
||
// A rule list compiles asynchronously, so it can fail after the generation was already refused.
|
||
let refused = MobileWebShellLoadStateMachine()
|
||
precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable")
|
||
precondition(refused.failed(.isolationUnavailable) == nil)
|
||
precondition(refused.failed(.renderProcessGone) == nil)
|
||
precondition(refused.finished() == nil)
|
||
precondition(refused.started() == nil)
|
||
|
||
refused.reset()
|
||
precondition(refused.failed(.generationUnreadable)?.reason == "generation-unreadable")
|
||
|
||
// A document is heard only between its own commit and the end of that load.
|
||
let arming = MobileWebShellLoadStateMachine()
|
||
precondition(!arming.hasCommittedDocument)
|
||
_ = arming.started()
|
||
// The previous document is alive and same-origin until the next one commits.
|
||
precondition(!arming.hasCommittedDocument)
|
||
arming.committed()
|
||
precondition(arming.hasCommittedDocument)
|
||
|
||
// A new prop triple: the committed document is the one being replaced.
|
||
arming.reset()
|
||
precondition(!arming.hasCommittedDocument)
|
||
arming.committed()
|
||
arming.documentEnded()
|
||
precondition(!arming.hasCommittedDocument)
|
||
|
||
// A failure ends the document, and nothing after it re-arms: a retry is a remount.
|
||
arming.committed()
|
||
_ = arming.failed(.renderProcessGone)
|
||
precondition(!arming.hasCommittedDocument)
|
||
arming.committed()
|
||
precondition(!arming.hasCommittedDocument)
|
||
}
|
||
|
||
static func checkResponseHeaders() {
|
||
let document = MobileWebShellResponseHeaders.forPath(
|
||
"/",
|
||
contentType: "text/html; charset=utf-8",
|
||
byteCount: 12
|
||
)
|
||
precondition(document["Content-Security-Policy"] == MobileWebShellCsp.header)
|
||
precondition(document["Content-Type"] == "text/html; charset=utf-8")
|
||
precondition(document["Content-Length"] == "12")
|
||
precondition(document["Cache-Control"] == "no-store")
|
||
precondition(document["X-Content-Type-Options"] == "nosniff")
|
||
// HTTPS images must not receive session identity; see MobileWebShellResponseHeaders.
|
||
precondition(document["Referrer-Policy"] == "no-referrer")
|
||
|
||
// The policy rides the document alone; on a subresource response it is inert.
|
||
for path in ["/index.html", "/assets/aa.js", "/manifest.json", "/assets/bb.png"] {
|
||
let headers = MobileWebShellResponseHeaders.forPath(
|
||
path,
|
||
contentType: "text/javascript; charset=utf-8",
|
||
byteCount: 0
|
||
)
|
||
precondition(headers["Content-Security-Policy"] == nil)
|
||
// Referrer policy governs requests only when set on the document.
|
||
precondition(headers["Referrer-Policy"] == nil)
|
||
precondition(headers["Cache-Control"] == "no-store")
|
||
precondition(headers["X-Content-Type-Options"] == "nosniff")
|
||
}
|
||
}
|
||
|
||
static func checkNavigationErrors() {
|
||
let ignorable = MobileWebShellNavigationError.isIgnorable
|
||
// Our own stopLoading on a prop update, and every navigation the policy delegate refuses.
|
||
precondition(ignorable(NSURLErrorDomain, NSURLErrorCancelled))
|
||
precondition(ignorable("WebKitErrorDomain", 102))
|
||
// Anything else is the document failing to load, which is the caller's cue to redownload.
|
||
precondition(!ignorable(NSURLErrorDomain, NSURLErrorNetworkConnectionLost))
|
||
precondition(!ignorable(NSURLErrorDomain, NSURLErrorResourceUnavailable))
|
||
precondition(!ignorable("WebKitErrorDomain", 101))
|
||
precondition(!ignorable("WebKitErrorDomain", NSURLErrorCancelled))
|
||
// WKErrorDomain has no frame-load codes at all, so 102 there is some other error.
|
||
precondition(!ignorable("WKErrorDomain", 102))
|
||
precondition(!ignorable("SomeOtherDomain", 102))
|
||
}
|
||
|
||
static func bridgeSource(
|
||
isOurWebView: Bool = true,
|
||
isMainFrame: Bool = true,
|
||
hasCommittedDocument: Bool = true,
|
||
originProtocol: String = MobileWebShellOrigin.scheme,
|
||
originHost: String = session
|
||
) -> MobileWebShellBridgeSource {
|
||
MobileWebShellBridgeSource(
|
||
isOurWebView: isOurWebView,
|
||
isMainFrame: isMainFrame,
|
||
hasCommittedDocument: hasCommittedDocument,
|
||
originProtocol: originProtocol,
|
||
originHost: originHost
|
||
)
|
||
}
|
||
|
||
static func acceptsBridge(_ source: MobileWebShellBridgeSource) -> Bool {
|
||
MobileWebShellBridge.accepts(source, sessionId: session)
|
||
}
|
||
|
||
static func checkAppliedProps() {
|
||
func props(
|
||
directory: String = "/gen/aa",
|
||
session: String = session,
|
||
bridge: Bool = true
|
||
) -> MobileWebShellAppliedProps {
|
||
MobileWebShellAppliedProps(
|
||
generationDirectory: directory,
|
||
sessionId: session,
|
||
bridgeEnabled: bridge
|
||
)
|
||
}
|
||
|
||
precondition(props().matches(props()))
|
||
precondition(!props().matches(props(directory: "/gen/ab")))
|
||
precondition(!props().matches(props(session: "sess-01JN_aZ8")))
|
||
precondition(!props().matches(props(bridge: false)))
|
||
}
|
||
|
||
static func checkBridgeAcceptance() {
|
||
precondition(acceptsBridge(bridgeSource()))
|
||
// Simulator: WebKit ASCII-lowercases custom-scheme hosts; exact session comparison refuses every message.
|
||
precondition(acceptsBridge(bridgeSource(originHost: "sess-01jn_az9")))
|
||
precondition(acceptsBridge(bridgeSource(originHost: "SESS-01JN_AZ9")))
|
||
|
||
// A frame we did not serve.
|
||
precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ8")))
|
||
precondition(!acceptsBridge(bridgeSource(originHost: "")))
|
||
precondition(!acceptsBridge(bridgeSource(originHost: "sess-01JN_aZ9.evil")))
|
||
// Unicode folding would accept unminted Kelvin-sign hosts.
|
||
precondition(!MobileWebShellBridge.accepts(
|
||
bridgeSource(originHost: "\u{212A}ey"),
|
||
sessionId: "key"
|
||
))
|
||
precondition(MobileWebShellOrigin.asciiLowercased("\u{212A}EY") == "\u{212A}ey")
|
||
|
||
// Another scheme reaching the same handler.
|
||
precondition(!acceptsBridge(bridgeSource(originProtocol: "https")))
|
||
precondition(!acceptsBridge(bridgeSource(originProtocol: "")))
|
||
precondition(!acceptsBridge(bridgeSource(originProtocol: "orca-mobile-web ")))
|
||
|
||
// A subframe, and a message routed to a WebView that is not ours.
|
||
precondition(!acceptsBridge(bridgeSource(isMainFrame: false)))
|
||
precondition(!acceptsBridge(bridgeSource(isOurWebView: false)))
|
||
|
||
// The replaced same-origin document remains alive until the new load commits.
|
||
precondition(!acceptsBridge(bridgeSource(hasCommittedDocument: false)))
|
||
|
||
// No applied session is not an empty one: nothing may be accepted before a load.
|
||
precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: ""), sessionId: ""))
|
||
precondition(!MobileWebShellBridge.accepts(bridgeSource(originHost: "a b"), sessionId: "a b"))
|
||
}
|
||
|
||
static func checkBridgePostTarget() {
|
||
func canPost(
|
||
_ host: String?,
|
||
_ sessionId: String = session,
|
||
committed: Bool = true
|
||
) -> Bool {
|
||
MobileWebShellBridge.canPost(
|
||
toFrameOriginHost: host,
|
||
sessionId: sessionId,
|
||
hasCommittedDocument: committed
|
||
)
|
||
}
|
||
|
||
precondition(canPost(session))
|
||
// The same ASCII fold as acceptance: WebKit reports the host lowercased.
|
||
precondition(canPost("sess-01jn_az9"))
|
||
|
||
// Never post without an accepted frame: before first message, after failure/death, or with bridge disabled.
|
||
precondition(!canPost(nil))
|
||
|
||
// A frame from another document, and a frame under no session at all.
|
||
precondition(!canPost("sess-01JN_aZ8"))
|
||
precondition(!canPost("\u{212A}ey", "key"))
|
||
precondition(!canPost(session, ""))
|
||
precondition(!canPost("", ""))
|
||
|
||
// Provisional navigation invalidates the held frame until commit.
|
||
precondition(!canPost(session, committed: false))
|
||
}
|
||
|
||
// A frame armed by document A must never receive posts for document B.
|
||
static func checkBridgeTargetLifecycle() {
|
||
func canPost(_ target: MobileWebShellBridgeTarget<String>, committed: Bool) -> Bool {
|
||
MobileWebShellBridge.canPost(
|
||
toFrameOriginHost: target.originHost,
|
||
sessionId: session,
|
||
hasCommittedDocument: committed
|
||
)
|
||
}
|
||
|
||
var target = MobileWebShellBridgeTarget<String>()
|
||
precondition(target.frame == nil && target.originHost == nil)
|
||
precondition(!canPost(target, committed: true))
|
||
|
||
// didCommit for document A, then A's first accepted message.
|
||
target.clear()
|
||
target.arm(frame: "frame-a", originHost: session)
|
||
precondition(target.frame == "frame-a")
|
||
precondition(canPost(target, committed: true))
|
||
|
||
// B’s provisional navigation clears arming and commit state.
|
||
target.clear()
|
||
precondition(target.frame == nil)
|
||
precondition(!canPost(target, committed: false))
|
||
|
||
// B’s commit rearms acceptance; clear A’s frame so it cannot become postable again.
|
||
target.clear()
|
||
precondition(!canPost(target, committed: true))
|
||
|
||
// B speaks for itself, and that is the only way a post reaches it.
|
||
target.arm(frame: "frame-b", originHost: session)
|
||
precondition(target.frame == "frame-b")
|
||
precondition(canPost(target, committed: true))
|
||
}
|
||
|
||
static func checkBridgeByteCap() {
|
||
let cap = MobileWebShellBridge.maxMessageByteCount
|
||
precondition(cap == 640 * 1024)
|
||
precondition(MobileWebShellBridge.acceptsByteCount(0))
|
||
precondition(MobileWebShellBridge.acceptsByteCount(cap - 1))
|
||
precondition(MobileWebShellBridge.acceptsByteCount(cap))
|
||
precondition(!MobileWebShellBridge.acceptsByteCount(cap + 1))
|
||
|
||
let gate = MobileWebShellBridgeGate()
|
||
precondition(gate.refusedCount == 0)
|
||
precondition(gate.accepts(byteCount: cap))
|
||
precondition(gate.refusedCount == 0)
|
||
precondition(!gate.accepts(byteCount: cap + 1))
|
||
precondition(!gate.accepts(byteCount: cap * 2))
|
||
precondition(gate.refusedCount == 2)
|
||
}
|
||
|
||
// One allow/opener decision; TypeScript’s readBridgeExternalLinkUrl owns scheme filtering.
|
||
static func checkNavigationVerdict() {
|
||
let foreign = "https://example.com/artifact-link"
|
||
let document = "orca-mobile-web://\(session)/"
|
||
func verdict(
|
||
_ url: String? = "https://example.com/artifact-link",
|
||
isMainFrame: Bool = true,
|
||
isDocumentUrl: Bool = false,
|
||
isShellLoad: Bool = false,
|
||
hasGesture: Bool = true,
|
||
isDownload: Bool = false
|
||
) -> MobileWebShellNavigationVerdict {
|
||
MobileWebShellNavigationPolicy.verdict(
|
||
url: url,
|
||
isMainFrame: isMainFrame,
|
||
isDocumentUrl: isDocumentUrl,
|
||
isShellLoad: isShellLoad,
|
||
hasGesture: hasGesture,
|
||
isDownload: isDownload
|
||
)
|
||
}
|
||
precondition(verdict() == .cancelAndOffer(foreign))
|
||
// WebKit reports our own load with main-frame source and target.
|
||
precondition(verdict(document, isDocumentUrl: true, isShellLoad: true, hasGesture: false) == .allow)
|
||
// Never offer/reload other document-URL navigations; sandboxed top navigation can report no gesture.
|
||
precondition(verdict(document, isDocumentUrl: true, hasGesture: false) == .cancel)
|
||
precondition(verdict(document, isDocumentUrl: true, hasGesture: true) == .cancel)
|
||
// Refuse non-shell document loads from page or preview frames.
|
||
precondition(verdict(document, isDocumentUrl: true, isShellLoad: false, hasGesture: false) == .cancel)
|
||
// A top-page meta refresh or a redirect to somewhere else: refused, and never opened.
|
||
precondition(verdict(hasGesture: false) == .cancel)
|
||
// Gestured downloads reach the opener, never the document-load allowance.
|
||
precondition(
|
||
verdict(document, isDocumentUrl: true, isShellLoad: true, hasGesture: false, isDownload: true)
|
||
== .cancel
|
||
)
|
||
precondition(verdict(isDownload: true) == .cancelAndOffer(foreign))
|
||
// Document-URL downloads are never offered, regardless of gesture.
|
||
precondition(verdict(document, isDocumentUrl: true, isDownload: true) == .cancel)
|
||
// A subframe is the sealed preview loading itself, which is not the user leaving the app.
|
||
precondition(verdict(isMainFrame: false) == .cancel)
|
||
precondition(verdict(isMainFrame: false, hasGesture: false) == .cancel)
|
||
precondition(verdict(nil) == .cancel)
|
||
precondition(verdict("") == .cancel)
|
||
// The crossing cap, at it and one past it.
|
||
let cap = MobileWebShellNavigationPolicy.maxCancelledNavigationUrlCharacters
|
||
let prefix = "https://example.com/"
|
||
let atCap = prefix + String(repeating: "a", count: cap - prefix.count)
|
||
precondition(atCap.count == cap)
|
||
precondition(MobileWebShellNavigationPolicy.offerableUrl(atCap) == atCap)
|
||
precondition(MobileWebShellNavigationPolicy.offerableUrl(atCap + "a") == nil)
|
||
precondition(verdict(atCap + "a") == .cancel)
|
||
// A scheme the opener will refuse still crosses: one filter, in the half that updates.
|
||
precondition(verdict("javascript:alert(1)") == .cancelAndOffer("javascript:alert(1)"))
|
||
}
|
||
|
||
// Check flag/policy together: only one main-frame action may spend the shell’s own-load allowance.
|
||
static func checkOwnLoadIsSpentOnce() {
|
||
let document = "orca-mobile-web://\(session)/"
|
||
func decide(_ machine: MobileWebShellLoadStateMachine) -> MobileWebShellNavigationVerdict {
|
||
MobileWebShellNavigationPolicy.verdict(
|
||
url: document,
|
||
isMainFrame: true,
|
||
isDocumentUrl: true,
|
||
isShellLoad: machine.isShellLoad,
|
||
hasGesture: false,
|
||
isDownload: false
|
||
)
|
||
}
|
||
let machine = MobileWebShellLoadStateMachine()
|
||
machine.shellLoadStarted()
|
||
let first = decide(machine)
|
||
precondition(first == .allow)
|
||
// Spend at decision, before commit: WebKit may decide a second action first.
|
||
machine.shellLoadConsumed()
|
||
precondition(decide(machine) == .cancel)
|
||
// And the endings still drop it, for a load that is allowed and then never commits.
|
||
machine.shellLoadStarted()
|
||
machine.documentEnded()
|
||
precondition(decide(machine) == .cancel)
|
||
}
|
||
|
||
static func main() {
|
||
checkSessionIds()
|
||
checkRequestResolution()
|
||
checkAssetPaths()
|
||
checkContentTypes()
|
||
checkGenerationMap()
|
||
checkCsp()
|
||
checkLoadStateMachine()
|
||
checkResponseHeaders()
|
||
checkNavigationErrors()
|
||
checkNavigationVerdict()
|
||
checkOwnLoadIsSpentOnce()
|
||
checkAppliedProps()
|
||
checkBridgeAcceptance()
|
||
checkBridgePostTarget()
|
||
checkBridgeTargetLifecycle()
|
||
checkBridgeByteCap()
|
||
print("mobile web shell checks OK")
|
||
}
|
||
}
|