Skip to content

Add tests for Cross-Origin Storage API - #61811

Open
tomayac wants to merge 48 commits into
web-platform-tests:masterfrom
tomayac:cross-origin-storage
Open

tomayac wants to merge 48 commits into
web-platform-tests:masterfrom
tomayac:cross-origin-storage

Conversation

@tomayac

@tomayac tomayac commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This PR covers both the imperative API (navigator.crossOriginStorage) and the additional integrations for HTML, CSS, and JS.

tomayac added 4 commits August 7, 2026 13:10
Covers the imperative API (navigator.crossOriginStorage.requestFileHandle,
hash/origins validation, the create/write/read lifecycle, FileSystemWritableFileStream's
seek()/truncate()/verify-and-store behavior, idlharness), origins-based
disclosure scoping across same-site/list/wildcard tiers with GREASE'ing
tolerance, Permissions Policy integration, and the declarative CSS/HTML/JS
integrations (cross-origin-storage() in @font-face src, the crossoriginstorage
attribute on <link>/<script>, and the crossOriginStorage import attribute).

https://wicg.github.io/cross-origin-storage/
…mport attribute

Import attribute values must be string literals -- a SyntaxError for
static import and a TypeError for dynamic import() otherwise -- so the
array-valued crossOriginStorage examples ([] for same-site, [origin]
for a restricted list) were invalid. Switch to the same space-separated
string convention already used by the crossoriginstorage HTML attribute.
The static-import-crossoriginstorage.mjs resource in particular used
an array literal inside a static import's `with` clause, which would
not have parsed at all.
Rephrase the "Static form smoke test" comment to describe what the
test verifies rather than categorize where it came from; a scan of
the rest of the suite found no other such comments.
…on "declarative"

Dynamic import() is a function call, and even the static import form
needs a running module graph, so grouping it with the genuinely
declarative HTML/CSS integrations was misleading. Rename the
declarative-js/ directory to import-attribute/ (matching the file
names it already used) and update titles, test names, and comments
accordingly. Generic comments that referred to all three integrations
collectively now say "declarative and import-attribute" or "the other
integrations" instead of lumping JS in under "declarative".
@saschanaz

Copy link
Copy Markdown
Member

Tests for early proposals should have .tentative in filenames: https://web-platform-tests.org/writing-tests/file-names.html

.tentative : Indicates that a test makes assertions not yet required by any specification, or in contradiction to some specification. This is useful when implementation experience is needed to inform the specification. It should be apparent in context why the test is tentative and what needs to be resolved to make it non-tentative.

Tests for early proposals should have .tentative in the filename per
https://web-platform-tests.org/writing-tests/file-names.html.
@tomayac

tomayac commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @saschanaz, done — all test files now have .tentative in their names.

tomayac added a commit to tomayac/firefox that referenced this pull request Aug 9, 2026
Copies the cross-origin-storage/ test directory and the companion
interfaces/cross-origin-storage.idl (needed by idlharness's IDL
fetch) from web-platform-tests/wpt#61811.

Against this branch's implementation:
- idlharness, requestFileHandle-validation,
  requestFileHandle-create-and-read, origins-scoping: fully pass
  (all Window/dedicated-worker/SharedWorker/ServiceWorker globals).
- filesystemwritablefilestream-verify: 4 known, already-documented
  Phase 1 gaps -- seek()/truncate() (not implemented; COS's own spec
  never uses them, only FileSystemWritableFileStream's generic
  surface does) and ArrayBuffer/ArrayBufferView write() chunks (e.g.
  from piping a fetch() body; CrossOriginStorageSinkAlgorithms
  currently only accepts Blob/string).
- permissions-policy: 3/5 pass; the 2 failures are due to Firefox not
  parsing the modern `Permissions-Policy` HTTP response header at all
  yet (only the legacy `Feature-Policy` header name exists in-tree)
  -- a pre-existing platform gap unrelated to this feature.
- declarative-html/declarative-css/import-attribute subdirectories
  are out of scope: they test separate host-language integrations
  (HTML/CSS/JS import attributes) that this spec explicitly delegates
  to their own specifications, not implemented here.
None of the existing files test that the imperative API and the
declarative HTML/CSS/import-attribute integrations share one
underlying COS registry, as section 8 of the spec says they do --
each existing file only proves a given mechanism is internally
self-consistent (store and read back through the same mechanism).

Add a new file that stores through one mechanism and reads through a
different one, in both directions: imperative write -> declarative
HTML read, declarative CSS write -> imperative read, JS import
attribute write -> imperative read, and imperative wildcard write ->
declarative HTML read back to the same (storing) document.
- A non-storer widening an entry's origins list: every existing
  upgrade test has the original storer perform the widening write
  itself, never a different origin, even though the spec's closing
  note in § 3.4 explicitly says any origin supplying correct bytes
  can do this.
- origins: [] versus omitted origins: these are not equivalent (an
  explicit empty list authorizes nobody but the storer, not even a
  same-site sibling), but nothing exercised the distinction.
- The single-string origins convenience form (as opposed to a
  one-element array) was only exercised on the validation-rejection
  path, never on a successful create-then-read roundtrip.
- A second origin becoming a storing origin for a wildcard-scoped
  entry, and thereafter reading it back deterministically
  (GREASE-exempt) the same way the original storer already does.
Per the 'pending writer count' note in § 2.2, a create()'d handle
that createWritable() is never called on still permanently counts as
an outstanding writer against its entry, exactly like an abandoned
in-flight write. Confirm this doesn't block a second, independent
create request for the same hash from succeeding normally.
navigator.crossOriginStorage is annotated [SecureContext] but nothing
verified it. Add the conventional insecure-context.any.js (no
.https. in the name, so it runs over plain HTTP) asserting the
attribute is absent, matching the existing pattern used by e.g.
webusb/insecure-context.any.js.
§ 5.1's actual quota and maximum-origins-list-length thresholds are
implementation-defined and unobservable from script, so the boundary
itself (the write that trips QuotaExceededError, or the widening call
that finally gets capped) can't be triggered deterministically and
this file doesn't attempt to. Two 'must never fail' guarantees in the
spec text are implementation-independently testable, though: that
rewriting identical bytes under the same hash never consumes
additional quota, and that cumulative origins-list growth across many
separate writes never fails the write itself.
origins-scoping only covers the gate's negative direction, and deliberately
accepts either outcome because PHL membership is implementation-defined: it
stores content it invented, which cannot be on the list. A user agent that
disclosed nothing at all, or one that ignored the list and disclosed
everything, both pass it unchanged.

Reading a listed and an unlisted hash from the same cross-site origin, at the
same wildcard scope, is what makes the assertion meaningful -- neither half
alone is, since each is satisfiable by a degenerate implementation. Each is
read several times because GREASE'ing can suppress an authorized disclosure,
so a single refusal proves nothing; an unlisted hash is refused every time.

The listed resource is jQuery 3.7.1's minified build, from the list's core
section: it is admitted there because several independent CDNs serve it
byte-identically, which is also what makes it stable enough to pin a test to,
and it is MIT-licensed so it can be redistributed here. The vendored copy has
to stay byte-identical to what those CDNs serve -- adding a licence header
would change its hash and silently turn this into a test of an unlisted
resource, which the digest assertion guards against.

Adds cosRemoteReadDigest(), because the existing helper returns text and
comparing text is lossy for arbitrary stored bytes.
…ative tests

Two assertions could not fail regardless of implementation behaviour, so
they were not testing what they claimed to.

declarative-html/crossoriginstorage: a <link>'s rules keep applying for as
long as it is in the document, and t.add_cleanup() only runs once the whole
test is over. Clearing the marker's inline style does not stop an earlier
stylesheet from applying, so in the "omitting crossoriginstorage" test the
real stylesheet loaded a few lines above was still in effect and
assert_not_equals(markerColor(), 'rgb(1, 2, 3)') could never hold. The three
positive <link> tests had the same flaw in the opposite direction: their
colour assertions were satisfied by the first <link> whatever the second one
did, leaving the un-rejected load as their only real signal.

Each of these now removes the first <link> before loading the second, and
asserts the marker actually resets, so the following assertion is
demonstrably capable of failing.

declarative-css/cross-origin-storage: promise_rejects_js() requires a JS
Error subtype and rejects the assertion on its own argument before ever
evaluating the promise ("function DOMException() { [native code] }" is not
an Error subtype). document.fonts.load() rejects with a DOMException, so
this needs promise_rejects_dom(). The name is normative: CSS Font Loading
rejects a FontFace's [[FontStatusPromise]] with a "NetworkError" DOMException
when the resource fails to load, and FontFaceSet.load() propagates that
rejection. ("SyntaxError" applies only to ArrayBuffer sources, which these
tests do not use.)
@tomayac
tomayac force-pushed the cross-origin-storage branch from 7f695fd to 19a45a9 Compare August 15, 2026 21:05
A FileSystemFileHandle is serializable, so postMessage() is a second way
to obtain a handle for a COS entry alongside requestFileHandle(), and the
disclosure controls have to hold across it. Nothing covered that.

Adds five subtests: a readable handle survives a transfer to a same-origin
worker and to a same-origin frame with its bytes intact; a create-request
handle that was never written through does not become readable by being
transferred; readability is captured when the handle is serialized rather
than re-evaluated on arrival (re-evaluating would let a caller re-roll
GREASE'ing by transferring one handle repeatedly); and a handle posted to
a cross-origin frame is not delivered.

That last one restates a guarantee /fs already tests generically, but COS
leans on it for a disclosure property the /fs tests aren't concerned with,
so a regression should fail this suite too.
phl-listed-resource.js is jQuery 3.7.1 verbatim, because the test needs a
resource the Public Hash List actually lists. The minified bundle contains
setTimeout and console.* calls, which lint flags, failing CI on a file
that isn't ours to edit.

Ignores both, alongside the existing entry for annotation-model's ajv.min.js.
isSameEntry() and createWritable({keepExistingData: true}) were both marked
SPEC GAP, with the reasoning carried in the test files because there was nowhere
else for it to live. The specification now covers both, so cite it instead and
drop the claim that these are undecided.

The reasoning stays in keep-existing-data, since it is what the assertions
actually check. handle-surface's remaining gaps -- kind, name, move(), remove()
and createSyncAccessHandle() -- are still marked as such.
…Handle()

Not a spec gap: createSyncAccessHandle() rejects with an "InvalidStateError"
whenever the handle is not in a bucket file system, and the COS file system is a
root distinct from any origin's, so the File System Standard already fixes both
the refusal and its name.

The test had guessed NotSupportedError and named InvalidStateError only as the
plausible alternative. It is the required one.
name, move(), remove() and the permission queries are all decided now, so cite
the specification instead of recording a choice this file had to make.

move() and remove() move from NotSupportedError to NotAllowedError -- the
alternative the remove() note already argued was stronger -- and the permission
test gains the readwrite case it had deliberately left unpinned.
…isibility

Cover three recently specified behaviors:

- Cross-Origin-Storage-Allow-Origin header (allow-origin-header.tentative):
  a non-same-site origins list is intersected with the writing document's
  ceiling header, so an origin the header omits is dropped and refused
  deterministically, a write with no header discloses to no cross-site
  origin, being named by the header is a bound rather than a grant, and a
  "*" write needs no header. List-scoped writes run in a RemoteContext whose
  response carries the ceiling via pipeHeader, since the top-level document
  cannot set a header on itself.

- Permissions Policy over workers (permissions-policy-workers.tentative):
  the check resolves through a worker's owner set, so a blob: worker cannot
  escape a cross-origin-storage=() policy on the page that created it, the
  check still precedes hash validation in a worker, and a service worker
  (empty owner set) fails closed.

- Additive resource visibility (visibility-upgrade-list-preserved.tentative):
  a later "*" write adds the global grant without discarding an existing
  origins list, so a listed origin keeps its PHL-independent access; the
  same-site tier likewise survives a "*" write; and a storing origin keeps
  deterministic access. Because a clobbering implementation would turn every
  post-upgrade listed read into a permanent (not GREASE'd) NotFound for
  content never on the PHL, the file's meta assertion fails loudly on
  regression rather than passing vacuously.

Adds cosRemoteStore() and cosAllowOriginPipe() helpers and a service worker
resource.
… list writes

A non-same-site origins list is only honored for origins the writing
document's Cross-Origin-Storage-Allow-Origin header authorizes, so the
list-scoped tests here were storing against a ceiling they never set. Add a
companion .sub.headers whose ceiling names every origin in the cross-origin
pools (verified to resolve to exactly cosOriginPools()'s set), so each pool
origin a test lists is authorized. The one list-scoped write made from a
remote context (the non-storer-widening test) instead carries its own ceiling
via pipeHeader, since the ceiling is the writer's, not this document's.
# Conflicts:
#	interfaces/cross-origin-storage.idl
…workers

`global=worker` expands to dedicated, shared, and service workers, so these
six files expected requestFileHandle() to succeed in a
ServiceWorkerGlobalScope. That contradicts the spec and
permissions-policy-workers.tentative.https.html: a service worker's owner
set is empty, so the Cross-Origin Storage permission check fails closed and
every call rejects with NotAllowedError. No implementation could pass both.

Run them in windows, dedicated workers, and shared workers instead, as
insecure-context.tentative.any.js already does. The service worker case stays
covered by permissions-policy-workers.
The spec made resource visibility strictly additive: every entry grants its
storing origins and the origins same site with them, whatever its explicit
origins list holds. An explicit empty list therefore grants exactly what an
omitted origins value does.

This test still encoded the earlier model, where origins could be null and an
empty list withheld the entry even from a same-site sibling. Expect the
sibling to be authorized (subject to GREASE'ing) instead.

See WICG/cross-origin-storage#80.
… css

Match the explainer's section names and the sibling import-attribute and
fetch directories, and point the explainer links at the renamed anchors.
A create request registers nothing in the COS registry, so a hash whose only
write is still in flight now reads as an ordinary NotFoundError. It previously
rejected with a distinguishable NotAllowedError.

That distinguishable answer handed any origin one noiseless bit about any hash,
decided ahead of origins scoping, the Public Hash List, and GREASE'ing, and
without writing any bytes for the storage limit to bound. An abandoned create
handle also pinned its hash indefinitely, suppressing every other origin's
reads of it.

- requestFileHandle-create-and-read: flip the concurrent-read and
  abandoned-handle assertions to NotFoundError, and drop the placeholder
  cleanup rationale from the hash-mismatch tests.
- probe-budget: remove the pending-writer budget exemption, which no longer
  has anything to exempt.
- origins-scoping: add a regression test asserting that a cross-site origin
  gets the same answer for a hash with an in-flight write as for one that was
  never written.

getFile() on an unwritten create handle still rejects with NotAllowedError.
That gate is per handle, its answer is constant with respect to the registry,
and a handle cannot be deserialized cross-origin, so it crosses no origin
boundary.
tomayac added a commit to WICG/cross-origin-storage that referenced this pull request Sep 23, 2026
`complete a create request` no longer reads or writes the COS registry. An
entry is added only by `verify and store` or by `store a fetched resource in
Cross-Origin Storage`, in both cases after the complete bytes have been
verified against the hash. The read path loses its pending branch, so
`apply availability gating` is the first and only step that decides a read.

Previously a create request registered a placeholder entry, and a concurrent
read of it rejected with a distinguishable `NotAllowedError`, so that a caller
would not start a redundant multi-gigabyte download. That answer was available
to any origin, for any hash, with no GREASE'ing applied and without writing any
bytes, so it walked around `origins`, the Public Hash List, and GREASE'ing
together, and neither the storage limit nor eviction bounded it. An abandoned
create handle also pinned its hash in that state indefinitely, which suppressed
every other origin's reads of it.

- Drop `state` and `pending writer count` from the COS entry struct, and make
  `bytes` non-null. A registry entry is written by construction.
- Give `FileSystemFileHandle` a `COS hash` item, so a handle addresses a hash
  and needs no entry to exist. Serialize it alongside `COS origin`.
- Simplify `verify and store`: a failed write has nothing to clean up.
- Add an "In-progress writes" section recording the invariant and the reasons
  the placeholder was removed.

`getFile()` on an unwritten create handle still rejects with `NotAllowedError`.
That gate is per handle, its answer does not vary with the registry's contents,
and a handle cannot be deserialized cross-origin.

Tests: web-platform-tests/wpt#61811
Follows WICG/cross-origin-storage#86, which renames
the method, its CrossOriginStorageGetFileHandleHash and
CrossOriginStorageGetFileHandleOptions dictionaries, and the spec anchor.
…7151e9c

Reads by storing, listed, and same-site origins are now asserted strictly
with `cosAssertDisclosed()`, since the spec forbids GREASE'ing them and limits
its lookup budget and user-gesture gate to the "*" grant. List-tier tests
list a cross-site origin, integration tests take the scope ceiling from the
fetched resource's own Cross-Origin-Storage-Allow-Origin header, and every
integration test stores bytes unique to the run.

Named origins must be potentially trustworthy http(s) origins, SHA-1 is
rejected, and createWritable() and isSameEntry() on COS handles reject with
NotAllowedError. The fetch tests no longer run in service workers, which fail
the permission check. Font probes check each face's status, since
`document.fonts.check()` reports success for a face that failed to load.

Tests for behavior the spec leaves to implementations or to host
specifications move to .optional files: the lookup budget, Public Hash List
membership, fetch Response details, and queryPermission() and
requestPermission(). New tests cover worker ceilings, header parsing,
same-site extension of listed origins, additive grants, handle transfer,
algorithm validation, and the service worker fetch path.
The HTML integration tests loaded classic scripts and stylesheets
inserted by script. Adds coverage for the rest of what the
crossoriginstorage attribute applies to:

- module scripts, and <link rel=modulepreload>, whose preloaded module
  an import() of the broken URL then resolves to
- <link rel=preload> as script and as style, plus a preload without the
  attribute that must not be served from COS
- <link> and <script> written in markup and loaded by the parser
- crossoriginstorage without integrity, which has nothing to look up
  and so has no effect
The CSS integration tests loaded fonts through @font-face. Adds
coverage for the rest of what cross-origin-storage() applies to:

- parsing and serialization against the proposed grammar,
  cross-origin-storage( [ '*' | <string># ]? ), including arguments
  the grammar rejects, which invalidate the declaration as an unknown
  modifier does
- @import, with the same-site default and with *
- images, observed through the size a content: url() image gives its
  box, plus an image without the modifier that must not be served from
  COS

cos-resource.py gains kind=image, which serves a PNG with unique
trailing bytes, the way kind=font does for Ahem.

The two cross-origin integration tests open several remote contexts,
so they are marked as long-running.
An image sizes its box as soon as its header is decoded, before its
load finishes and its bytes are stored, so the CSS image test now waits
for the entry itself before requesting it from a URL that 404s.

The tests that open several cross-site contexts or workers take well
over the default timeout under load, so they are marked as
long-running.
The test that a list refuses an unlisted cross-site origin listed one
cross-site pool origin and read from another. Every origin in that pool
belongs to one site, and a listed origin admits the origins same site
with it, so the reader was admitted. It now lists an origin of the
document's own site and reads from the cross-site pool, as
origins-scoping does.

Adds a test that crossOriginStorage on a Request object takes part in
COS, also once the Request is copied, and marks the Permissions Policy
fetch test, which opens remote contexts, as long-running.
An engine reads only the RequestInit members it supports, so a getter
on the init dictionary detects crossOriginStorage, as for other
members such as duplex. Feature detection relies on this; the member is
not reflected on Request.
The broken-URL specifiers in the import attribute tests lacked a './'
prefix, which made them bare specifiers that fail to resolve before any
fetch, so the tests could not reach Cross-Origin Storage.

New tests cover a static import of a broken URL served from COS, a
static import with integrity alone that gets no COS participation, and
JSON and CSS modules stored into and served from COS.
A frame reads back what it wrote only under the same top-level site, an
origin's top-level writes don't reach its frames elsewhere, the
same-site baseline is keyed too, a first-party origins list admits the
listed origins first-party and under the writer's top-level site, and a
list written from a cross-site frame stays under that frame's top-level
site. A create request with no bytes doesn't make its caller a storing
origin.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants