Files
Jinwoo Hong a3c6d4266a fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27) (#21964)
* fix(mobile): admit https: images on the web shell's CSP (OTA phase C, ruling 27)

Native markdown and the native rich editor load images the author referenced
by URL, so the page has to as well or a remote image is a blank where native
paints a picture. `img-src` widens to `img-src 'self' data: https:` on both
platforms; `script-src`, `connect-src`, `object-src`, `frame-src` and
`child-src` do not move.

`http:` stays out, and the pins say so directly rather than by absence: the
Kotlin test's blanket `!contains("http")` could not survive `https:`, so both
native pins now check `http:` (not a substring of `https:`) and check that
`https:` appears in `img-src` and nowhere else, the same shape the `data:`
pin already had.

No behaviour change on released phones: the shell ships in no released tag
(mobile-v0.0.9 predates it), so this reaches devices with the Phase E native
build and not before.

Neither native module has a CI job, so both ran locally: swiftc over the
module plus MobileWebShellChecks, and
`:orca-mobile-web-shell:testDebugUnitTest`. Both were confirmed red against
the old directive first.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct what the sealed preview frame is stricter about

The doc comment said the page was deliberately stricter than the native
preview because it loads no remote image and runs no script. Since `img-src`
gained `https:` only the script half is true: the frame loads a remote image
exactly as the native WebView does.

Says instead what an artifact's image URL now is -- a channel that fires on
view and carries whatever its author encoded, with nothing dynamic behind it
because no script runs -- and names `referrerPolicy` as what keeps the
document's own origin out of the request.

Comment only; no behaviour and no test moves.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): measure both halves of the preview frame's image fence

"fetches nothing of the artifact that leaves the origin" stopped being what
the sealed arm proves once `img-src` gained `https:`. The fixture's foreign
origin is `http://127.0.0.1`, so its two images are refused on the scheme
alone and only the font is refused by `font-src 'none'`. Renamed to say
exactly that.

The half that was missing is an https arm. Playwright route interception
answers an `https://…invalid` origin in the page, so the arm needs no TLS
server and no new dependency, and a request only reaches the handler if the
policy let it out. Under the shipped header, on Chromium and WebKit, the
`<img>` and the CSS background are both requested -- `img-src` governs a
background too -- and the font still is not.

`artifact()` takes the subresource origin; the links stay on the cleartext
one so no existing navigation case changes.

Red-first: with `img-src 'self' data:` put back into the parsed Kotlin
policy, the new arm fails on both engines with `expected [] to deeply equal
[ '/css-bg.png', '/img.png' ]`. The directive was restored byte-identical
before this commit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(scripts): split the preview frame's settling out of the render check

The https arm pushed mobile-web-app-html-preview-render.test.mjs to 620
counted lines, over the 600 cap config/scripts carries. Split at a module
boundary rather than bumped: the four wait-and-settle functions are rig
mechanics with no assertion in them, and they now sit beside the diagnosis
module they already reported through.

`waitForLoadedFrame` and `settleAfterMount` are the two the render check
calls; `waitForRecordedNavigation` and `settleWithoutNavigation` stay
internal to the new module.

Move only. Same 20 tests pass on both engines.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): send Referrer-Policy: no-referrer on the shell document

`img-src https:` gave the page somewhere to send a request, and the document
origin is `orca-mobile-web://<sessionId>/`, so a request that carries a
referrer carries the session id to whatever host an artifact or a markdown
document named.

`referrerPolicy="no-referrer"` on the preview iframe does not cover it.
Measured in the render rig against a permissive control policy: WebKit puts
the embedder's URL on a srcdoc frame's image request despite the attribute,
and Chromium sends none. So the guarantee belongs on the document, where one
header covers every request the page makes, and it rides the document alone
with the policy -- the referrer of a request is decided by the document that
made it, so on a subresource response it would govern nothing.

WKWebView under the custom scheme is unverified: the rig is Playwright
WebKit over http, not WKWebView over `orca-mobile-web://`. The header is the
hedge, and it costs nothing if that host never leaked.

Pinned three ways, each confirmed red first:
- Swift, exit 133 with the header removed.
- Kotlin, MobileWebShellResponseHeadersTest "sends the policy on the
  document" FAILED at :17 with it removed.
- The rig, through a new `readShellDocumentHeaders` that parses the Kotlin
  source the way `readShellCsp` does and throws rather than returning an
  empty map. With the value flipped to `unsafe-url` the WebKit arm fails
  `expected [ …(2) ] to deeply equal [ null, null ]`; with the line deleted
  the parse throws "could not parse the shell document headers".

The rig's arm carries its own presence precondition: a third server serves
the shipped policy with `unsafe-url`, so the WebKit reading is the header
doing the work, and Chromium's null either way is pinned as the browser's
behaviour rather than sold as evidence the header arrived.

MobileHtmlPreview.web.tsx said the iframe attribute kept the origin out of
the request. Corrected to name the header, since the measurement above is
what disproved it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): quote the current directive where the old text was written down

Three comments still read `img-src 'self' data:`, so a grep for the old
directive found live prose that no longer matches the header. Each stays
about `data:`, which is what those paths rest on; only the quoted policy
changes.

The two remaining hits in the repo are src/main/browser/doc-preview-protocol,
which is the desktop preview's own policy and not this one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): name the surfaces img-src https: actually unblocks today

The comment justified `https:` with markdown and the rich editor, and
neither renders a remote image on the page. Verified in the tree:
MobileMarkdown paints `![](...)` as a tappable link at both of its image
branches and never mounts an Image, and it has no `.web` sibling, so that is
what native does too; MobileRichMarkdownEditor.web.tsx is a 92-line
multiline TextInput, still C7.6's plain source field.

What the directive unblocks today is four surfaces, none of them overridden
on the page:
- MobileAgentIcon's favicon, a hardcoded `google.com/s2/favicons` URL, used
  by thirteen callers including the session header and the worktree rows;
- MobileRepoIcon's project icon, a host-named favicon, avatar or upload, on
  the worktree list and the host workspace list;
- PRCommentCard's author avatar, from the review reply schema;
- the sealed HTML preview frame, which inherits the policy.

Markdown and the editor are named as the anticipated surfaces ruling 26
points at, so a later reader does not take the loosening as already covering
them. Both native pins carried the same wrong claim and are corrected.

That comment is the only record of why the policy loosened, so it says what
is true now and what is coming, separately.

Comment only: the parsed header is unchanged, checked through the harness
reader the render suite uses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): point the new source-control route pin at the current directive

Merge resolution, not a conflict git could see. #21957 landed the
source-control and review page routes on main while this branch was open,
and its render check pins the directive text twice: `cspHeader` by substring,
which survives the widening, and the Swift source by the quoted literal
`"img-src 'self' data:"`, which does not. Two PRs green alone, red on the
merge.

Both pins now read the current directive.

One comment goes with it. "Not one request left the origin, so there is
nothing for the policy to have refused" now needs saying why: `https:` is
admitted, so an empty host list is these two closures fetching nothing
rather than the policy refusing something. The avatar that would fetch needs
provider data this page never gets, which the file's own closing note
already explains.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): wait for the admitted images before reading their hits

CI's Chrome 152 recorded the CSS background and not the `<img>` by the time
the bounded settle returned, so both https arms failed on a count: "expected
[ '/css-bg.png' ] to deeply equal [ '/css-bg.png', '/img.png' ]" and
"expected 1 to be 2". The reads were absence-shaped -- two frames and 200 ms
-- and the claim they carry is a presence.

So the arms wait for their own evidence, the way the `'refusal'` arm already
does. `frameReady: 'images'` polls until both admitted paths are recorded,
bounded by nothing but the case's own `ctx.signal`. It sits after the marker
wait, because an image is requested by a document that has parsed, and the
arm hands its reader in rather than the settling module reaching for state
that belongs to an arm.

One reader now serves the wait and the reading. An arm that waits on one
list and asserts on another has proved nothing about the list it asserts on.

The `/probe.woff2` absence is untouched and is now an absence standing
behind two presences rather than beside them.

What the wait prints when it does not arrive, captured by making the paths
unsatisfiable against a 12 s case:

  [html-preview-render] the arm recorded ["/img.png","/css-bg.png"] of
  ["/css-bg.png","/img.png","/never-arrives.png"]; #remote
  {"complete":true,"naturalWidth":1,
  "currentSrc":"https://artifact-images.invalid/img.png?n=n1",
  "loading":null}: arm csp=shipped sandbox=product frameReady=images
  nonce=n1 | browser 147.0.7727.15 | ... | frames [...]

`complete` with a zero `naturalWidth` is a request that finished and
produced no image; `complete` false is one still in flight. So a Chrome that
never issues the request says which of those it was, instead of a bare count.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): say why an admitted image never arrived, and hand back the context

CI's Chrome 152 read the `<img>` as complete with a zero naturalWidth and a
resolved currentSrc while the route handler never saw the request, and the
CSS background from the same origin did reach it. The diagnosis could say
the image failed but not why, because nothing was watching the request.

Now four sources are, for the `.invalid` origin only, in a module of their
own so the rig file stays under its cap: `request` says whether the page
asked at all, `requestfailed` carries the browser's `errorText`, and CDP's
`Network.loadingFailed` adds `blockedReason` and `corsErrorStatus`, which is
the only place a refusal names itself once the request never reaches a route
handler. `Network.requestWillBeSent` records the resource type, the initiator
and the frame, which separates an image the parser found from one nothing
asked for. They fill arrays while an arm passes and are only read on abort.

Proved by forcing the abort rather than assuming: with the awaited paths made
unsatisfiable, the reading names the font's refusal in both vocabularies at
once, `failed [{"url":".../probe.woff2","errorText":"csp"}]` and `cdp
loadingFailed [{"errorText":"","blockedReason":"csp",...,"type":"Font"}]`,
beside `cdp sent` showing every request's type, initiator and frameId.

Teardown: `open()` now takes an explicit context and closes both the page and
the context in a `finally`. The close used to sit on the happy path, so an
arm whose wait aborted and whose result reads then raced vitest's teardown
left its page and its implicit context open on a browser every later case in
that engine still runs on.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): correct three rationales the widening left wrong

(a) A review comment's avatar is not a surface the widening unblocks.
PRCommentCard renders it only under `Platform.OS !== 'web'` and a component
test pins the skip, so on the page it never renders. Dropped from both native
rationales and moved to the anticipated list beside markdown and the editor,
with the reason each is anticipated rather than current.

(b) The Kotlin rationale quoted the iOS origin. Android serves from
`https://<sha256(sessionId) first 32 hex>.orca-mobile-web.invalid/`, so a
referrer there carries a stable per-session handle and not the id itself,
while iOS serves `orca-mobile-web://<sessionId>/` and carries it verbatim.
Both are something an image host can key on across requests, which is what
the header is for; each file now names its own origin.

(c) "Only the script half of that is stricter than native" overstated it.
`font-src 'none'` and `connect-src 'self'` are stricter too. Images are the
one of the four that stopped being stricter, and the comment now says which
three remain and why.

A fourth, found while checking (a): the skip's own comment justified itself
with `img-src` being `'self' data:`, so a provider avatar would be "one
refused request per card". That is no longer true -- the avatar would load
now -- so the skip is a page capability gap rather than a policy consequence.
Recorded as such at the guard. Whether to lift the guard is a ruling-26
question and not this PR's.

Comments only. The parsed policy and document headers are unchanged, checked
through the harness readers the render suite uses.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): probe why Chrome never asks for the artifact image

CI's read was decisive: on Chrome 152 only the CSS background was requested,
while the `<img>` reported complete with a zero naturalWidth and a resolved
currentSrc. A request that went out and failed cannot produce both readings,
so the next probe asks the frame rather than the network.

On abort it now reads, inside the artifact frame: readyState, the init
script's own moment, document.images.length, every
`performance.getEntriesByType('resource')` name, the navigation entry types,
and for #remote its src, isConnected, complete, naturalWidth, currentSrc and
the outcome of decode(). A resource entry for a URL the rig never saw would
mean the request left the frame and died before reaching it.

Then it issues a `new Image()` at a URL that has never existed and reports two
seconds later whether the rig saw it. That splits the two live explanations: if
the fresh request is seen and the artifact's was not, the frame can fetch and
the parser-inserted element is the cause; if neither is seen, requests from
this frame are not reaching the rig at all. Subframe document commits are
counted from mount, because a second parse is a new window and leaves nothing
behind to count, and a second parse could be meeting a failure the first
cached.

`cdp sent` was empty on CI even for a request Playwright did record, so the
page's own session is blind to the frame. Chromium isolates sandboxed iframes
into their own process, srcdoc included, so flattened Target.setAutoAttach now
puts each child target on the same connection with Network.enable on the
child, and the attached list reports whether the frame is a separate target
at all.

The navigation arm gets the same reading, since CI showed it fails on its own
rather than behind the aborted image arms.

Verified by forcing the abort rather than assumed. Locally the reading prints
one subframe parse, decode resolved, every resource the document fetched, and
`fresh ... issued true seen true`, with the attached list empty, which is
consistent with this Chrome not isolating the frame and its page session
seeing the requests.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): time the artifact image against the frame's attachment

CI's second read showed the frame did issue the request -- it has a
resource-timing entry and decode rejected with EncodingError -- while the rig
saw only the CSS background, and a fresh image created later from the same
frame was both issued and seen. The remaining question is whether the entry
starts before anything was listening to that frame.

So the entry is now reported in full for the element under test:
responseStatus, transferSize, encodedBodySize, nextHopProtocol, startTime and
duration. A zero status with a zero transferSize is a fetch that reached the
network stack and came back with nothing, which is what an unintercepted
request looks like once `.invalid` fails to resolve.

Both sides of the comparison get a wall clock: `Target.attachedToTarget` and
Playwright's own `frameattached` now carry the moment they fired, and every
recorded request carries the moment it was seen. An entry that starts before
the attachment is the race stated rather than inferred.

Abort path only; the passing run is unchanged.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): serve the artifact's https assets from a real TLS listener

Interception could not measure what the directive admits. Chrome 152 isolates
the sandboxed srcdoc frame into its own target and the parser-inserted `<img>`
is the document's first fetch, issued before interception attaches there: the
request escaped to the real network, `artifact-images.invalid` did not
resolve, and the rig recorded nothing while the frame's own resource timing
showed the fetch and a later fresh image was both issued and seen.

So the assets come from a listener that is already accepting before the page
exists. It cannot be raced: the request arrives or it does not, and either
answer is the measurement. Hits and referrers are recorded server-side, the
way this rig's cleartext origin already does it, and read per arm by nonce.
`img-src 'self' data: https:` matches on scheme, so `https://127.0.0.1:<port>`
exercises the same directive as any other https host.

Lifecycle: started in beforeAll before any browser, closed in afterAll beside
the other servers. Its certificate is generated per run by openssl into the
suite's own scratch directory under `mobile/.tmp`, which the root gitignore
already covers and into which the server writes a second `.gitignore` as well;
the key never leaves that directory and nothing trusts it, since the context
is created with `ignoreHTTPSErrors`. No arm shares state: one hit list keyed
by each arm's nonce, and the permissive-Referrer-Policy control stays what it
was, a second bundle server serving the page, because the control is the
document's header and not the image host's.

The navigation record moves off interception too. It is now `page.on('request')`,
one subscription over every frame, armed after the rig's own `goto` exactly
where the route used to be registered; the route stays only for what only a
route can do, refuse the navigation. That answers the top-nav arm's `recorded
[]`: its record depended on the same per-target interception.

And the arms stop swallowing their clicks. `click(...).catch(() => {})` made a
tap that never landed and a tap that produced no navigation the same empty
counter; `open()` now records the error and the two top-nav arms assert it is
null before reading any count.

One correction to the reading added in the previous commit. The resource-timing
fields came back zero for a request that had plainly succeeded: they are opaque
cross-origin. The listener now sends `Timing-Allow-Origin`, after which
transferSize, encodedBodySize and nextHopProtocol carry real values.
`responseStatus` still reads zero on a successful request, so the comment names
the three that discriminate rather than the four that are printed.

24/24 on both local engines.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): compare the artifact fetch and the attachment on one clock

The early-or-late comparison spanned two clocks and could not answer the
question it was written for. Every `at` in the request log is Node's
`performance.now()`, counting from process start; the resource entry's
`startTime` is the frame's own, counting from that document's navigation. A
frame entry reads as earlier than a Node attachment by roughly the process
uptime, so the comparison would have reported the race as confirmed on every
run, including runs where there was no race. A green CI would not have caught
it.

So the comparison is stated where both numbers actually live: `asked` against
`attached` in the request log, on the Node clock alone. `startTime` and
`duration` stay, labelled as the frame's own account and explicitly not
comparable to an attachment time. The module docstring says the same, so the
next reading added here starts from the rule rather than rediscovering it.

The commit message of b5e82065f3 carries the same overstatement and is left
as it stands; this is the correction.

Also the stale route-handler references, now that the asset listener records
the secure origin and the navigation record is a page subscription. Three were
in the review; two more were not, and both were stale for the same reason:
`waitForRecordedNavigation`'s docstring still credited the route with
recording a main-frame navigation, which stopped being true when the record
moved off interception, and the request log described a refusal as one the
request never reached a route handler with. The route now only refuses; it
counts nothing. The one remaining mention is the deliberate contrast in the
rig that says the record is the page's event and not the route's.

Comments only. 24/24 on both local engines.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 08:34:38 -04:00

668 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:` URI; `https:` is the favicon, project icon and avatar the
// page already renders, and the sealed preview frame. See MobileWebShellCsp.
precondition(directives.contains("img-src 'self' data: https:"))
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: https:"])
precondition(!header.contains("blob:"))
// Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so
// this still refuses a cleartext source anywhere in the header.
precondition(directives.filter { $0.contains("https:") } == ["img-src 'self' data: https:"])
precondition(!header.contains("http:"))
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 document origin is the session id, and `img-src https:` gives the page somewhere to send
// it. 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)
// Rides the document with the policy: the referrer of a request is decided by the document
// that made it, so on a subresource response this would govern nothing.
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)))
// 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")
}
}