mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* feat(mobile): offer a cancelled top-frame navigation to the shell's opener Both shells cancelled every navigation off their own document in silence: iOS `decidePolicyFor` allowed only `isMainFrame && isDocumentUrl`, Android's `shouldOverrideUrlLoading` dropped anything whose resolved path was not "/". Nothing opened. That is the whole of ruling 29's "if they do not": a user tapping a link inside C7.10's sealed HTML-preview frame reaches the top frame as a navigation request, and the shell was the only thing that could act on it. A cancelled main-frame navigation now reaches JS as `onExternalNavigation` and goes through the same `Linking.openURL` the `externalLink` notify already uses. The scheme list is not restated natively: the native side caps the string and says which frame it came from, and `readBridgeExternalLinkUrl` decides what opens in the half that ships over the air. A subframe navigation is never offered, because that is the sealed preview loading itself. swiftc check: OK (`checkCancelledNavigation` added, the whole suite runs). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): render the HTML preview in a sealed srcdoc frame on the page C7.6 gave the page the artifact's source, which is the native component's Source tab and half its job (ruling 8). Ruling 26 makes that debt: the Preview tab comes back as an `<iframe sandbox srcdoc>` inside the page's own document. `srcdoc` rather than a `blob:` URL, and no CSP change at all. Measured on Chromium and WebKit: a `srcdoc` frame has no URL for `frame-src` to match and inherits its embedder's policy instead, so it is admitted under the shipped `frame-src 'none'`, while a `blob:` frame is refused by `frame-src` on both and refused a second time in WebKit by the `frame-ancestors 'none'` it inherits. Two independent fences seal it, and the render check measures each on its own: the sandbox grants neither `allow-scripts` nor `allow-same-origin`, and the inherited `script-src 'self'` refuses the artifact's inline script even when a control arm grants `allow-scripts`. The inherited `img-src` and `font-src 'none'` govern its subresources, against a no-header control where the same three are fetched. `allow-top-navigation-by-user-activation` is the one token granted (ruling 29), so a tapped link becomes one top-frame navigation the shell now opens externally, while a `<meta refresh>`, a form submit, `target="_blank"` and any script-initiated navigation produce none. `lucideBarrelPlugin` is exported from the bundle builder so the check builds the toolbar's icons the way the page does rather than carrying a second shim. config/scripts suite, this file: 14 passed, 0 errors, exit 0. Control runs: a literal `sandbox` in the JSX reds 4, an added `allow-scripts` reds the script fence and the token census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the preview's sealed frame where the degradation was pinned The three HTML-preview cases in this file described the state ruling 26 retires: no toggle, no frame, the source only. They now pin the frame's shape through the test renderer -- the artifact reaches it as `srcDoc`, the sandbox grants neither `allow-scripts` nor `allow-same-origin`, both toggle positions exist, and Source takes the frame away with it -- and the "never renders the html itself" case becomes "never puts it anywhere but the frame", counted rather than merely absent. What a browser does with that frame stays in the render check, which is the only thing that can answer it. The rich Markdown editor's half is unchanged: it is still the plain field, and item C is a later PR. Two mocks added: `Pressable`/`ScrollView` on the react-native double, because the toggle renders one, and `lucide-react-native`, whose barrel imports a `LucideProvider` its own context module does not export and so does not load under vitest at all. 9 passed, exit 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a link-activated top-frame navigation, even to the document F1, blocking, with F5 and F6 folded in because they are the same decision and splitting them would mean three rewrites of one function. F1: `<a href="/" target="_top">` and `href=""` in an artifact resolve against the embedder's base, so both named the shell's own document URL -- which both shells ALLOWED (iOS `isDocumentUrl`, Android's path `/`). One tap inside the sealed preview reloaded the shell's page: bridge target cleared, load state restarted, page state gone. A navigation a human started is now never allowed, whatever it names; it is offered instead, and `cancelledShellNavigationTarget` drops `orca-mobile-web:` in silence exactly as it drops `/h/other`. The page rewriting its own path carries no gesture and is still allowed. F5: the OFFER is gated on the same gesture, so a top-page meta refresh or a redirect is cancelled and never opened externally. F6: iOS returned early on `shouldPerformDownload` before the offer, so `<a download>` was dead on iOS and opened on Android. The early return goes; a download is refused rather than allowed when nothing started it, and a gesture-started one reaches the opener on both platforms. The allow half and the offer half are now one function per platform (`MobileWebShellNavigationPolicy.verdict`, `mobileWebShellNavigationVerdict`), so they cannot drift. The gesture is the platform's own answer: `.linkActivated` on iOS, `request.hasGesture()` on Android. Native tests, both platforms: document URL + gesture refused and offered; document URL without gesture allowed; foreign + gesture cancelled and offered; foreign without gesture cancelled and silent; download both ways; subframe never offered. swiftc OK; control run with the gesture rule removed exits 133. Gradle MobileWebShellDroppedNavigationTest tests=8 failures=0 errors=0. Also corrected: the screen comment that claimed the document's own reloads reach the handler (they never do), and the prop doc, which now states the gesture rule. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): count own-origin top-frame navigations, and drop the goto cap F2: `page.setDefaultTimeout(4000)` capped `page.goto` at 4 s while every sibling render check uses the 30 s default, so under load the first WebKit cases redded on the navigation rather than on anything they assert. The cap goes; the per-action timeouts that needed to be short are already passed at their call sites. F1's page-side half: the rig now routes the page's own origin as well as the foreign one and counts main-frame navigations to each separately, with two cases pinning that `href="/"` and `href=""` each produce exactly one own-origin top-frame request. Playwright is not the shell, so what these state is the request the shell is handed; refusing it is the native tests' job and the docstring names which ones. The own-origin route is registered after the initial load, because it aborts main-frame navigations and the first `goto` is one. The foreign-tap and meta-refresh cases now also assert zero own-origin navigations, so a fix that merely moved the target would not pass. 16 passed, exit 0, no Errors line. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait for the preview frame's own load, never a clock CI read the child frame before its srcdoc committed: frameUrl came back '' and the control arm's script as not yet run. The frame list, the frame's URL and anything read inside it settle at their own moments, and a 900 ms wait reads whichever of them has happened -- on a loaded runner, none. Polls for a child frame at about:srcdoc with its load fired, bounded by the case's own timeout, and an override arm now resolves on the document its srcdoc assignment commits rather than on the assignment. Red-first: with a 2.5 s mount delay standing in for a loaded runner, the paint case failed on both engines before this and all 16 cases pass after. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say whose violations the preview rig reads The list is the main frame's: securitypolicyviolation does not cross into a frame, so an empty one says the embedder raised none and says nothing about the artifact's own style, image or font. A listener inside the frame cannot be the fix -- the fence under test is that nothing in the artifact runs. So the comment now claims what the reading supports, and names where the frame's containment is actually measured: the pixel for its inline style, the counting server for its img-src and font-src. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): announce which side of the preview toggle is showing The Preview/Source pair carried a label each and nothing else, so which one was showing lived only in the active background -- invisible to a screen reader on both surfaces. Each button is now a tab carrying its selected state, inside a tablist, and the two files' toolbars stay character-identical so the page and the phone announce the same thing. Red-first: the new case renders both siblings and failed on both for the missing role before this. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the WebView mock like the file's other hosts The anti-slop gate refuses a bare `object` parameter. Takes the same shape as the react-native mocks beside it, which pass it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): allow only the load the shell itself started The document URL was allowed whenever the host reported no gesture, so a navigation the shell never asked for could reload the page out from under the session. Measured against a real WKWebView off-device: a sandboxed subframe navigating the top frame to the document URL arrives as `.other` with no gesture at all, and Chromium's own docs allow hasGesture() to be false for a request a human started. Census first: nothing in the page navigates the top frame -- no location assignment, reload, replace, window.open or form -- the router moves by pushState and replaceState only, so the rule needs no gesture and no page cooperation. Both shells now raise a flag around their own load and drop it at commit, and allow a main-frame navigation only while it is up. Everything else naming the document is refused and never offered, since offering it would send the user out of the app. iOS carries the second discriminator the same probe measured: sourceFrame is the main frame for the shell's own load and the subframe for a subframe's top navigation, so a subframe can never take the allow path. Red-first: the Swift checks and the Kotlin tests were written first and failed to compile against the old signature. 9 Kotlin tests, 54 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): point the meta-refresh arm at the embedder's own URL The fixture pointed off-origin, so its own-origin assertion could not move whatever the frame did. The new arm refreshes to `/`, which resolves against the embedder's base, and pins zero top-frame requests on a counter the `href="/"` case proves reads 1 in the same rig. It also counts what the frame asks for itself, with a presence control that attributes the fence: with `allow-same-origin` and no policy the same fixture navigates the frame to the embedder's `/`, and with the policy dropped but the product's token kept it navigates nothing, so the opaque origin is what refuses it rather than the CSP. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read what an action produced, not what a clock allowed The 600 ms after every action is gone. An arm that expects a navigation now returns the moment the route handler records it, with a deadline only so a click that missed its target says so instead of spending the case's timeout. An arm that expects none waits for two painted frames inside the page and one 200 ms drain for the popup queue, which is a browser-process event with no in-page counterpart; the docstring says why that one is bounded. Measured and reported rather than claimed: with the new wait replaced by a no-op every arm still passes, because the reads that follow are each a round trip. It is insurance against the runner load that produced the frame-commit race, not a fix for a failure seen here. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the settling branch as a ternary What oxlint's prefer-ternary asks for, and the changed-code gate with it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): find the preview frame by its element, not its URL CI timed out on all seven preview cases on one engine: the poll waited for a child frame whose URL reads about:srcdoc, and that browser reports an empty URL for a srcdoc frame, so every case ran to its own timeout. The same difference had already shown as `expected '' to be 'about:srcdoc'`. The frame is now the element: waitForSelector('iframe') then contentFrame(), with readiness taken from the fixture's own marker inside it. Nothing compares a frame URL any more -- the paint case reads the element's srcdoc attribute and the absence of src instead, which is what "parsed inside the frame rather than fetched into it" actually means. The one arm whose artifact navigates the frame away says so rather than waiting for a marker that is not coming. Red-first: with the old poll keyed on a URL the browser never reports, both engines time out exactly as CI did; the new wait passes 18/18 with the 2.5 s mount delay still injected. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make a frame that never becomes ready say what it saw The runner's Chrome read the preview frame's URL as empty where three chromium builds here read about:srcdoc: bundled headless, the headless shell, and --headless=old, all 147. So the difference is not reproducible locally and the next CI run has to carry its own diagnosis. The marker wait is bounded well inside the case timeout, and on expiry it reports the frame's URL, the srcdoc attribute's length and the page's CSP violation list -- which separates a frame the policy refused from one that was merely slow, the two readings that look identical from a timeout. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): run the containment arms the comment only claimed The comment said the fixture navigates nothing with the policy dropped and the product's token kept, but no arm ran it: the control dropped both fences at once. Both single-fence arms exist now, either of which would hold. Measured rather than assumed, and one of them is not what the comment said. The token alone: the navigation never starts, no request, no violation. The policy alone, with allow-same-origin granted: the navigation does start and frame-src refuses it, which the embedder reports as its own violation. The engines differ only in what is left in the frame -- chromium an error page, WebKit the artifact -- so neither is asserted; what is asserted is that the request never reaches the server. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): refuse a download that names the shell's own document The document branch skipped downloads, so `<a href="/" download>` fell through to the offer path carrying the shell's own URL. Harmless in practice, because the opener's scheme list drops it, but it contradicted the policy's own comment and the prop doc, and it left the one URL that must never be offered reaching the boundary. The branch now covers a download too: refused, from either frame, gesture or not, and never offered. A gesture-started download of anything else still reaches the opener. Red-first on both platforms: the Swift checks exited 133 and the Kotlin row failed against the old policy. 10 navigation tests, 55 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop the own-load flag wherever a document ends The flag lived beside the load call and had to remember every ending separately, so iOS missed two: a prop update that fails before it loads, and a renderer that died. Both left it raised, and a navigation to the document URL during that window would have been allowed. It now lives in the load state machine, which every ending already runs through -- a commit, a failure, a dead renderer, a prop update, a reset -- on both platforms, so there is nothing left to remember. The view raises it and reads it, and drops it nowhere. The Android residual is stated in the policy rather than papered over: between loadUrl raising the flag and onPageStarted dropping it, a navigation to the document URL from inside the preview frame would be allowed, because that callback says nothing about which frame asked and no host discriminator exists. It needs a generation switch and a tap in that window; iOS closes the same gap with sourceFrame. Red-first: the new Swift row failed to compile and the Kotlin row with it. 12 load-state tests, 56 in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): spend the own-load flag on the allow, not on the commit The flag stayed raised from the load until didCommit, so a second main-frame action naming the document inside that window was allowed too and replaced the document. WebKit can decide a second action before the first one starts, so the commit is too late to be what spends it. The allow itself spends it now, before the decision goes back, and every ending still drops it for a load that is allowed and never commits. Red-first: the new check composes the machine with the policy -- the seam the flag and the rule meet at -- and failed to compile against the old machine. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop raising an own-load flag Android never consults WebViewClient's javadoc, verbatim: "This callback is not called for all page navigations. In particular, this is not called for navigations which the app initiated with loadUrl(): this callback would not serve a purpose in this case, because the app already knows about the navigation." So the flag guarded nothing on this platform and, while raised, was the one thing that could have let a competing request to the document URL through. The view passes isShellLoad = false always now, the machine drops the field it had no raiser for, and the policy comment carries the quote. Nothing reaching that callback is the shell's own load, so nothing naming the document is allowed there at all -- which also closes the generation-switch window the residual named, so that paragraph goes. No red to show: this is a removal, and the behaviour it leaves is the refusal the existing rows already pin. What a device proof must check is stated in the policy instead: a WebView that did route its own load here would have it refused and the load state would sit at loading. 55 tests in the module. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): settle every arm, not only the ones that tap An arm with no action read its counters as soon as the frame's marker appeared, so a zero-delay meta refresh could dispatch after the reading. The arms that pin zero were the ones relying on it. Every arm settles now, and what it settles on is what it expects: the sealed refresh arms take the bounded no-navigation path, and the loose arm waits for a recorded navigation that is neither main-frame nor foreign -- its own frame's -- rather than the main-frame wait it would never satisfy. Red-first: with the settling removed and the refresh moved to 2 s, the loose arm reads 0 on both engines; with it back, 1 on both, the delay still in. A 0.4 s refresh passes either way, which is why the finding was invisible. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): wait for what the artifact's script wrote, not for the element The two-fences control asserts the inline script ran, and the marker element it waited for exists from parse time, so the arm could read window.__ran before the script had touched it. Under a loaded runner that reads 0, which is CI's "expected +0 to be 1" on chromium. Readiness is now per-arm: 'script' waits for the script's own write, 'load' for the arm whose artifact navigates the frame away, 'artifact' for the rest. Red-first: with the inline script's write delayed 1.5 s, the old arm fails on both engines with that exact message and the new one passes, delay still in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): bound the rig's waits by the case timeout and nothing else Two inner deadlines, 20 s and 15 s, were racing the outer one they sit inside, so a slow runner could fail a case on a number this file picked rather than on the one the case declares. Both now run to vitest's own `ctx.signal`, which aborts when the case times out. On abort the rig prints its reading -- the frame's URL, the srcdoc length, the violation list, or the navigations it did record -- and lets the case fail as the timeout it is. Nothing is rethrown from that path: a rejection raised after vitest has given up on a case has nobody left to catch it, and an unhandled one fails a run whose every test passed. Red-first: with the marker selector pointed at an element that never appears and the case timeout cut to 8 s, the diagnostic prints and the case fails as `Test timed out in 8000ms` rather than hanging in silence. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): ask a stuck preview frame everything it can still answer The old diagnostic said only that a frame never parsed, and its violation list was the top document's -- securitypolicyviolation does not cross frames, so it said nothing about what the frame itself refused. It now prints the browser version, the arm it came from, the iframe element's srcdoc length and sandbox, contentDocument.readyState and contentWindow.href (which answer for a same-origin arm and report `refused` for an opaque one, so the arm's own origin is in the log), and every Playwright frame with its url, name, readyState, body length, marker presence, window.__ran and its own violations. Per frame, because the page's init script installs the collector in every frame -- measured on both engines -- and CDP evaluates inside an opaque frame whose scripts are blocked. Two corrections that the local probes forced. The reading is sampled while waiting and printed from the last sample: read at the abort it lost its race with vitest's teardown and printed nothing at all. And two arms had never been given the case's signal, so their waits could not be bounded or diagnosed. The diagnosis moves to its own module because the test file is at its line limit, and because the bound and the reading it prints are one thing. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): build a widened control frame instead of relaxing a live one A live frame cannot be relaxed. Sandbox flags are fixed on a browsing context when it is created, and Chrome 152 keeps the original ones through a srcdoc reassignment while still parsing the new document -- so the control arms that widened the product's own frame stayed sealed on the runner, and CI read a script that never ran and a refresh that never navigated. Chromium 147 here honours the relaxation, which is why it passed locally for a year of runs. The override now clones the element, sets the sandbox on the clone, gives it the artifact and replaces the product's frame with it, so the widened flags are there from creation -- the way the product does it, since React sets the attribute before insertion and never after. The product's own arms are untouched: a null override still returns immediately. And the control can no longer pass for the wrong reason on any engine. The header-keeping arm now reads the violation raised inside the frame: a script-src refusal can only happen if the sandbox let the script start, so it separates "the policy held" from "the frame was never widened", which the old arm could not. The loose arm pins an empty list beside it, the sealed arm pins an empty one too, and those three readings are the whole fence story. The violations come from each frame's own collector, because the embedder never sees them. Two diagnostic repairs the local probes forced: the browser version is read once at open, since asking at the abort printed "browser unknown" in the CI log this exists for, and the reading is sampled immediately as well as every five seconds, since a wait that only prints "no reading was taken" says nothing. Red-first: with the widening disabled, both engines fail exactly as CI did -- 180 s timeouts on the script arm -- and the diagnostic names the arm, the version and the sandbox it actually had. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): put the toggle's selected state where a browser reads it CodeRabbit is right, and the browser says so: react-native-web's createDOMProps never reads accessibilityState, so on the page the tab pair emitted role="tab" and no aria-selected at all. The test renderer could not see it, because it reports the props the component was handed rather than the DOM they become. Both siblings carry aria-selected beside accessibilityState now -- the phone's screen reader takes the latter, the browser the former -- and the toolbars stay character-identical. Red-first, in a real browser on both engines: the rig now reads every [role="tab"] element's aria-selected before and after the tap, and it read null for both positions before this line existed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
658 lines
32 KiB
Swift
658 lines
32 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"))
|
|
// Non-ASCII letters and digits satisfy Character.isLetter/isNumber, so the ASCII gate is load
|
|
// bearing: an IDNA-mapped host would not be the origin we minted.
|
|
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)
|
|
// The same ASCII-only fold as the bridge: a Kelvin-sign host is a host nobody minted, and a
|
|
// caseInsensitiveCompare here would serve it every asset.
|
|
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 "/" reaches the document: a second URL for the same bytes would answer without the CSP
|
|
// header, which rides the document response alone.
|
|
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)
|
|
// A JSON string is not a JSON number, and true and 1.0 are not the integer 1, though NSNumber
|
|
// bridges all three to something `as? Int` accepts.
|
|
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'"))
|
|
// A file preview is a `data:<mime>;base64,` URI the page composed from a reply it already
|
|
// holds; see MobileWebShellCsp.
|
|
precondition(directives.contains("img-src 'self' data:"))
|
|
precondition(directives.contains("connect-src 'self'"))
|
|
precondition(directives.contains("worker-src 'none'"))
|
|
precondition(directives.contains("frame-src 'none'"))
|
|
precondition(directives.contains("base-uri 'none'"))
|
|
precondition(directives.contains("form-action 'none'"))
|
|
precondition(directives.contains("frame-ancestors 'none'"))
|
|
// 'unsafe-inline' is granted to style-src and to nothing else: the page's code still has to
|
|
// arrive as a fetched same-origin script, which is the directive that matters.
|
|
precondition(directives.filter { $0.contains("unsafe-inline") } == ["style-src 'self' 'unsafe-inline'"])
|
|
precondition(!header.contains("unsafe-eval"))
|
|
// Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a
|
|
// directive that grew one would fail here instead of passing a blanket absence check.
|
|
precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data:"])
|
|
precondition(!header.contains("blob:"))
|
|
precondition(!header.contains("\r") && !header.contains("\n"))
|
|
}
|
|
|
|
static func checkLoadStateMachine() {
|
|
precondition(MobileWebShellFailureReason.generationUnreadable.rawValue == "generation-unreadable")
|
|
precondition(MobileWebShellFailureReason.isolationUnavailable.rawValue == "isolation-unavailable")
|
|
precondition(MobileWebShellFailureReason.documentLoadFailed.rawValue == "document-load-failed")
|
|
precondition(MobileWebShellFailureReason.renderProcessGone.rawValue == "render-process-gone")
|
|
|
|
// The own-load flag's whole lifetime, which is what decides whether a navigation to the document
|
|
// may be allowed. Raised only by the view's own `load`, and dropped by anything that ends the
|
|
// document -- a commit, a failure, a dead renderer, a prop update that never loaded.
|
|
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)
|
|
|
|
// The document's path is not an input here, and that is the point: the page rewrites its own
|
|
// with history.replaceState before its first render, so `didFinish` arrives at a URL no policy
|
|
// would allow. What is asked instead is whether this load committed.
|
|
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")
|
|
|
|
// 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)
|
|
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)))
|
|
// A triple that could not be honoured is still applied: re-entry reads the props, never whether
|
|
// the install succeeded, so a corrupt generation reports its failure once rather than on every
|
|
// commit for the life of the mount.
|
|
precondition(props(directory: "/gen/corrupt").matches(props(directory: "/gen/corrupt")))
|
|
|
|
// A fourth prop that nobody compared is a prop that silently never reloads, so the record's
|
|
// shape is pinned here rather than left to whoever adds the field.
|
|
let fields = Mirror(reflecting: props()).children.compactMap(\.label).sorted()
|
|
precondition(fields == ["bridgeEnabled", "generationDirectory", "sessionId"])
|
|
}
|
|
|
|
static func checkBridgeAcceptance() {
|
|
precondition(acceptsBridge(bridgeSource()))
|
|
// Simulator-measured: WebKit reports the custom scheme's host ASCII-lowercased, so the session
|
|
// we minted never equals the host verbatim. Exact equality here 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")))
|
|
// ASCII folding only: U+212A KELVIN SIGN lowercases to "k" under Unicode case folding, so a
|
|
// caseInsensitiveCompare would accept a host nobody minted.
|
|
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 document the current props replaced: same session, same origin, still alive between
|
|
// `stopLoading` and the next commit, speaking for a load already reported as `loading`.
|
|
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"))
|
|
|
|
// Nowhere to post, all four for the same reason: no frame has been accepted. A page that has
|
|
// never spoken, a document whose load failed, a renderer that died, a bridge not installed.
|
|
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("", ""))
|
|
|
|
// In flight: a navigation has started and not committed, so there is no document to post into
|
|
// even while a frame from the one being replaced is still held.
|
|
precondition(!canPost(session, committed: false))
|
|
}
|
|
|
|
/// The target across one document replacing another, in the order the navigation delegate runs:
|
|
/// a frame armed by document A is never what a post to document B goes to.
|
|
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))
|
|
|
|
// didStartProvisionalNavigation for document B. Refused twice over: nothing armed, and nothing
|
|
// committed to post into.
|
|
target.clear()
|
|
precondition(target.frame == nil)
|
|
precondition(!canPost(target, committed: false))
|
|
|
|
// didCommit for document B. Arming re-opens, so the clear has to happen here as well or A's
|
|
// frame becomes postable again as B's.
|
|
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))
|
|
|
|
// The cap is on UTF-8 bytes, not characters: a multi-byte payload must not buy extra room.
|
|
let wide = String(repeating: "\u{1F600}", count: 4)
|
|
precondition(wide.count == 4 && wide.utf8.count == 16)
|
|
|
|
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)
|
|
}
|
|
|
|
/// The whole navigation decision, which is one function so the allow half and the offer half
|
|
/// cannot drift. The rule is the frame and the gesture, not the scheme: TypeScript's
|
|
/// `readBridgeExternalLinkUrl` owns which URLs open, and a second scheme list here would be two
|
|
/// rules that drift.
|
|
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,
|
|
isFromSubframe: Bool = false,
|
|
isDocumentUrl: Bool = false,
|
|
isShellLoad: Bool = false,
|
|
hasGesture: Bool = true,
|
|
isDownload: Bool = false
|
|
) -> MobileWebShellNavigationVerdict {
|
|
MobileWebShellNavigationPolicy.verdict(
|
|
url: url,
|
|
isMainFrame: isMainFrame,
|
|
isFromSubframe: isFromSubframe,
|
|
isDocumentUrl: isDocumentUrl,
|
|
isShellLoad: isShellLoad,
|
|
hasGesture: hasGesture,
|
|
isDownload: isDownload
|
|
)
|
|
}
|
|
precondition(verdict() == .cancelAndOffer(foreign))
|
|
// The shell's own load, which is the only navigation to the document this view ever performs.
|
|
// Measured on WebKit: `webView.load` arrives with target and source both the main frame.
|
|
precondition(verdict(document, isDocumentUrl: true, isShellLoad: true, hasGesture: false) == .allow)
|
|
// Everything else that names the document is refused, whatever the host says about a gesture,
|
|
// and is never offered -- handing the shell's own URL to the opener would bounce the user out.
|
|
// The host is not trusted to report the gesture: measured on WebKit, a sandboxed subframe
|
|
// navigating the top frame to the document URL arrives with no gesture at all.
|
|
precondition(verdict(document, isDocumentUrl: true, hasGesture: false) == .cancel)
|
|
precondition(verdict(document, isDocumentUrl: true, hasGesture: true) == .cancel)
|
|
precondition(
|
|
verdict(document, isFromSubframe: true, isDocumentUrl: true, isShellLoad: true, hasGesture: false)
|
|
== .cancel
|
|
)
|
|
// The second discriminator, on its own: a load the shell did not start is refused even when the
|
|
// initiating frame is the main one, which is the page rewriting its own document away.
|
|
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)
|
|
// A tap inside the sealed preview is exactly a subframe-initiated foreign navigation, and that
|
|
// is the one thing the artifact is allowed to ask for.
|
|
precondition(verdict(isFromSubframe: true) == .cancelAndOffer(foreign))
|
|
// A download is not a document load, so it is refused there rather than allowed; started by a
|
|
// tap it reaches the opener, which is what makes `<a download>` behave as it does natively.
|
|
precondition(
|
|
verdict(document, isDocumentUrl: true, isShellLoad: true, hasGesture: false, isDownload: true)
|
|
== .cancel
|
|
)
|
|
precondition(verdict(isDownload: true) == .cancelAndOffer(foreign))
|
|
// `<a href="/" download>`: a download that still names the shell's own document, which is the
|
|
// one thing never handed to the opener. Refused from either frame, gesture or not.
|
|
precondition(verdict(document, isDocumentUrl: true, isDownload: true) == .cancel)
|
|
precondition(
|
|
verdict(document, isFromSubframe: true, 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)"))
|
|
}
|
|
|
|
/// The own-load flag against the policy that reads it: one load allowed, and only one.
|
|
///
|
|
/// The flag and the rule are separate types, and the gap between them is where a second main-frame
|
|
/// action to the same URL before the first commits would have been allowed too. So the seam is
|
|
/// checked rather than each half on its own.
|
|
static func checkOwnLoadIsSpentOnce() {
|
|
let document = "orca-mobile-web://\(session)/"
|
|
func decide(_ machine: MobileWebShellLoadStateMachine) -> MobileWebShellNavigationVerdict {
|
|
MobileWebShellNavigationPolicy.verdict(
|
|
url: document,
|
|
isMainFrame: true,
|
|
isFromSubframe: false,
|
|
isDocumentUrl: true,
|
|
isShellLoad: machine.isShellLoad,
|
|
hasGesture: false,
|
|
isDownload: false
|
|
)
|
|
}
|
|
let machine = MobileWebShellLoadStateMachine()
|
|
machine.shellLoadStarted()
|
|
let first = decide(machine)
|
|
precondition(first == .allow)
|
|
// Spent by the allow itself, not by the commit that follows it: WebKit can decide a second action
|
|
// before the first one starts, and that one would have replaced the document.
|
|
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")
|
|
}
|
|
}
|