ferrijs-std 0.5.0

Node and web standard library for the ferrijs QuickJS runtime: WHATWG Streams, Events, AbortController, Buffer, crypto, fs, os, url, zlib and the capability model they enforce (partly derived from awslabs/llrt, Apache-2.0).
Documentation

ferridriver-jsstd

Vendored subset of awslabs/llrt (Apache License 2.0), providing the WHATWG Streams implementation, the node:os module, and the pieces they depend on for the ferridriver QuickJS runtime.

Upstream: 0.9.0-beta, re-synced against awslabs/llrt@0a10758 (main, 2026-09-06). Every module is taken from that one commit.

upstream crate module here
llrt_utils utils
llrt_context context
llrt_exceptions exceptions
llrt_events events
llrt_abort abort
llrt_encoding encoding
llrt_buffer buffer
llrt_json json
llrt_crypto crypto
llrt_os os
llrt_fs fs
llrt_path pathutil
llrt_stream_web stream_web
llrt_zlib zlib
llrt_compression compression
llrt_string_decoder string_decoder
llrt_perf_hooks perf_hooks
llrt_tty tty
llrt_navigator navigator
llrt_url url
llrt_util text
llrt_test test (dev)

llrt_stream is NOT vendored, and not because we chose against it: llrt has no node stream module. llrt_stream registers no ModuleDef and answers no specifier — it is an internal trait library (Readable / Writable / SteamEvents) that llrt's own fs, net and child_process implement. llrt_stream_web is the module, and it is stream/web. There is likewise no querystring anywhere in llrt.

The rest of llrt — its hyper/fetch stack, timers, console — is deliberately not vendored: ferridriver has its own, over reqwest. fs is vendored because it IS Node's fs, which is what a suite expects; only the Rust path helpers of llrt_path come with it (pathutil) — the path MODULE stays ferridriver's. os is vendored because ferridriver has nothing equivalent and the module is pure host introspection with no overlap with the automation stack. From llrt_util only the four text codecs are taken (TextEncoder, TextDecoder and their stream forms); its format / inherits / inspect are node::util's, which are richer.

What a host installs

Two entry points, and nothing else to remember:

  • jsstd::init(ctx) — every global this crate provides: DOMException, Event / EventTarget, AbortController / AbortSignal, the Streams surface, Buffer / Blob / File, crypto, TextEncoder / TextDecoder (+ their stream forms), URL / URLSearchParams, atob / btoa, structuredClone and performance.
  • jsstd::modules::modules() — every Node / web MODULE it serves, each entry carrying its specifiers, the ModuleDef the ES loader declares, and the object require() returns: path, buffer, os, util, events, assert (+ /strict), url, process, timers (+ /promises), crypto, zlib, string_decoder, perf_hooks, tty, stream/web. The host merges that list into its own loader, its require table and its bundler's external list, so the three cannot drift apart.

src/node/ — ferridriver-authored

Not everything Node exposes has a usable upstream in llrt. llrt_util is TextEncoder/TextDecoder plus format and inherits (no promisify, no inspect, no types), and llrt_assert is a single ok. Those modules are written here instead, under src/node/, so the runtime still has exactly one implementation of each surface:

module why it is ours
node::inspect The util.inspect / util.format renderer, moved out of ferridriver-script's console so console.log, util.format and util.inspect cannot drift apart
node::deep_equal Structural equality for util.isDeepStrictEqual (and assert.deepStrictEqual when it lands)
node::util The util module
node::assert The assert module (upstream llrt_assert is a single ok)
node::process A sandbox-safe process — inert identity and timing, with env and cwd() supplied by the host — and its module form
node::timers The module form of web::timers, plus timers/promises
node::path The path module, moved out of ferridriver-script's node_compat
node::bytes The one JS-value-to-Vec<u8> walk: BufferSource, Buffer, byte arrays, encoded strings. crypto, the compression streams, Buffer.from and setInputFiles all read through it — there were three separate walks before

src/node/ carries its own rustfmt.toml re-enabling formatting (the crate disables it for the vendored subtree) and follows the repo's house style. It is compiled under this crate's relaxed lints because pedantic's needless_pass_by_value cannot be satisfied by an rquickjs callback, which must take owned JS values.

src/web/ — ferridriver-authored

Web-platform globals llrt has no upstream for. Same formatting rules as src/node/.

module what it is
web (mod.rs) atob / btoa (the WHATWG forgiving-base64 algorithm, which base64::STANDARD does not implement), structuredClone, and performance.now() / timeOrigin over a monotonic base. llrt_buffer's module form reads atob / btoa off the globals, so installing them here is what makes require('buffer').atob resolve
web::form_data FormData. It holds entries and nothing else: a host serializes them with its own multipart writer and hands parsed bodies back through from_entries
web::compression CompressionStream / DecompressionStream (gzip, deflate, deflate-raw) over the vendored TransformStream
web::timers setTimeout / setInterval / clearTimeout / clearInterval / setImmediate / queueMicrotask. NOT installed by init: a host supplies a CallbackPolicy so ambient state (ferridriver carries an allow.net grant) survives from arming the timer to running the callback
web::blob_bytes The bytes-and-type read of a Blob / File value
web::js_iterator The live-iterator protocol object FormData's entries / keys / values return

Keeping it re-syncable

Sources are kept byte-close to upstream, including upstream's 4-space formatting, so a re-sync against a newer llrt stays a mechanical diff. The crate therefore does not inherit the workspace lints (see its Cargo.toml), and cargo fmt must not be pointed at it.

Re-sync recipe (from a checkout of llrt):

for m in utils:libs/llrt_utils context:libs/llrt_context \
         zlib:modules/llrt_zlib string_decoder:modules/llrt_string_decoder \
         perf_hooks:modules/llrt_perf_hooks tty:modules/llrt_tty \
         navigator:modules/llrt_navigator compression:libs/llrt_compression \
         encoding:libs/llrt_encoding exceptions:modules/llrt_exceptions \
         events:modules/llrt_events abort:modules/llrt_abort \
         os:modules/llrt_os buffer:modules/llrt_buffer \
         fs:modules/llrt_fs \
         json:libs/llrt_json crypto:modules/llrt_crypto \
         url:modules/llrt_url \
         stream_web:modules/llrt_stream_web; do
  name="${m%%:*}"; path="${m##*:}"
  cp -R "$LLRT/$path/src" "src/$name" && mv "src/$name/lib.rs" "src/$name/mod.rs"
done
# `text` takes only llrt_util's four codec files; its own mod.rs stays.
for f in text_encoder text_decoder text_encoder_stream text_decoder_stream; do
  cp "$LLRT/modules/llrt_util/src/$f.rs" "src/text/$f.rs"
done
# per-module first, then the cross-crate rewrite (BSD sed has no \b — use perl)
for name in utils context encoding exceptions events abort os buffer json crypto url text stream_web \
            zlib string_decoder perf_hooks tty navigator compression; do
  find "src/$name" -name '*.rs' | while read -r f; do
    perl -pi -e "s/\bcrate::/crate::${name}::/g" "$f"
  done
done
find src -name '*.rs' | while read -r f; do
  perl -pi -e 's/\bllrt_([a-z_]+)/crate::$1/g' "$f"
done

Then re-apply the local deltas below.

Local deltas

Everything here is a fix or a visibility widening, never a behaviour change for ferridriver's convenience. Upstream candidates.

  1. Upstream regressions we do NOT take. Still true at the 2026-09-06 main sync: upstream still ships the two transform-stream bugs listed in deltas 2 and 3 below — and has since changed transform_stream_error_writable_and_unblock_write to take _e and ignore it, moving further from the spec. A future re-sync must keep OUR versions of stream_web/transform/{controller,stream}.rs, stream_web/writable/mod.rs and the visibility widenings; taking upstream wholesale reintroduces a hung read() and a JS_FreeRuntime assertion at teardown.

  2. abort/abort_signal.rs — the sleep-tokio arm imports CtxExtension from llrt_utils::ctx, where it does not exist; it lives in llrt_context. Repointed at crate::context. (Upstream only builds the default sleep-timers arm, which is why this never surfaced there.)

  3. stream_web/transform/controller.rsTransformStreamDefaultControllerPerformTransform was missing spec step 3: reacting to the transform promise's rejection by erroring the stream. A transform() that threw left both sides live, so a pending reader.read() never settled.

  4. stream_web/transform/controller.rsTransformStreamErrorWritableAndUnblockWrite was missing WritableStreamDefaultControllerErrorIfNeeded, so an errored transform left its writable in the "writable" state with an unresolved write request. Added stream_web::writable::writable_stream_error_if_needed for it.

  5. Visibility widenings onlySizeAlgorithm / SizeValue / SizeFunction / NativeSizeFunction from pub(super) to pub(crate), and writable_stream_default_controller_error to pub(crate). Upstream these were crate-visible because each module was its own crate; nesting them under one crate narrowed them below what their own public API needs.

  6. Feature gatessleep-tokio is on by default. sleep-timers is deliberately not a Cargo feature (the timers module is not vendored, so --all-features would otherwise enable an arm that cannot compile); it is declared to rustc as a known-but-never-set cfg via check-cfg in Cargo.toml, which keeps the upstream cfg arms compiling out silently.

  7. rquickjs half feature — enabled because the synced utils/bytes.rs and stream_web/readable/byob_reader.rs handle Float16Array. Without it PredefinedAtom::Float16Array does not exist and f16 has no TypedArrayItem impl.

  8. Testsabort::abort_signal::tests::test_abort_signal is no longer gated on sleep-timers, so it covers the sleep-tokio path we build. Two regression tests were added to stream_web/transform/tests.rs for deltas 2 and 3.

  9. os/mod.rs — no Windows arm. llrt_os's windows.rs is not vendored: ferridriver targets macOS and Linux, and that arm needs four Windows-only dependencies (whoami, windows-registry, windows-result, windows-version).

  10. os/unix.rsgetpwuid_r instead of the users crate. Upstream read the login name and shell through users 0.11, which has been unmaintained since 2021, and at the 2026-09-06 sync moved to uzers 0.12, the maintained fork. The replacement calls getpwuid_r directly — the same call either crate makes — including its ERANGE grow-the-buffer protocol, so the one-line upstream swap is not taken and neither crate is a dependency here.

  11. os/statistics.rs — real CPU times. Upstream returns times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } for every CPU, with the comment "cannot be obtained at this time". sysinfo does not expose them, but the kernel does: /proc/stat on Linux and host_processor_info on macOS, which is where libuv reads them for Node. Ticks are converted to milliseconds through _SC_CLK_TCK. Darwin does not account interrupt time, so irq stays 0 there — as it does in libuv.

  12. os/mod.rsfill() / os_object() split. Upstream fills the module's default export inline inside evaluate. ferridriver serves every native module twice, as an ES module and as a synchronous require() namespace, and its loader requires both to read from one place, so the body moved into a function.

  13. os feature gates. Upstream's system / statistics / network features are declared here too (all on by default) so the #[cfg] arms stay exactly as upstream wrote them.

Known gaps against Node

  • os.constants (signal, errno, priority and dlopen tables) is not implemented upstream and is not added here.
  • networkInterfaces() marks link-local and multicast addresses internal: true; Node marks only loopback interfaces internal.
  1. buffer/blob.rs and buffer/file.rs — three fixes. Both files ARE vendored and init defines both classes (an earlier version of this note said neither was taken). Blob::stream copies the bytes out before building the pull closure: upstream captures the JS ArrayBuffer in a native closure, a cycle the collector cannot see, which trips JS_FreeRuntime's list_empty(&rt->gc_obj_list) assertion at teardown. File::from_bytes goes through Blob::from_bytes rather than into_js, which made a JS array of numbers that new Blob([...]) stringified (File.from_bytes(b"hi") read back as "104105"). And buffer/mod.rs chains File.prototype to Blob.prototype after defining both classes, since rquickjs classes do not inherit and upstream leaves file instanceof Blob false.

  2. buffer/class.rs is upstream's buffer.rs, renamed. A buffer module inside a buffer module trips clippy::module_inception, which is on by default and the repo's gate runs -D warnings.

  3. buffer/mod.rsequals and toJSON. Node defines both on Buffer.prototype; upstream defines neither, and the hand-written class this vendoring replaced had both, so not adding them would be a regression. Added after set_prototype rather than inside the vendored file, so class.rs stays a mechanical diff. Upstream candidates.

  4. llrt_encoding's build script is not vendored. It only calls llrt_build::set_nightly_cfg(); this repo pins stable. As of the 2026-09-06 sync upstream has dropped its last rust_nightly arm (bytes_to_utf16_string now uses the stable as_chunks), so no vendored file reads either cfg; rust_nightly and nightly stay declared as known-but-never-set cfgs in Cargo.toml so a future upstream arm compiles out silently rather than warning.

Known gaps against Node — Buffer

Buffer is a real Uint8Array subclass, so every typed-array method works and index access reads bytes. Missing against Node: the string-aware overrides of includes / indexOf / lastIndexOf / fill (the Uint8Array versions are inherited, so they take byte values, not strings), swap16 / swap32 / swap64, compare, and Buffer.poolSize.

  1. crypto/provider/{ring,openssl,graviola}.rs are not vendored. Only the pure-Rust provider (crypto-rust, upstream's own default) is taken; the other three back-ends would each add a system dependency. Their feature names are declared as known-but-unset cfgs. Upstream's _modern-webcrypto marker (ML-DSA, ML-KEM, the hybrid KEMs, ChaCha20-Poly1305, SHA-3 / cSHAKE / TurboSHAKE, supports, getPublicKey, encapsulate* / decapsulate*) is declared and on: every upstream provider enables it, and its back-ends (ml-dsa, ml-kem, chacha20poly1305, sha3, shake, cshake, keccak, sponge-cursor, ctutils) are all pure Rust. provider/modern.rs is provider-independent upstream and is vendored as is.

  2. crypto / json macro imports. iterable_enum and str_enum are #[macro_export]ed, so they live at the crate root rather than under utils — the import lines are repointed at crate::.

  3. Hash crates keep their oid feature. sha1 / sha2 / md-5 are taken with oid (and aes-gcm with hazmat): PKCS#1 v1.5 signing needs AssociatedOid, and WebCrypto allows 32- and 64-bit GCM tags, which are gated behind those features in the 0.11 releases.

  4. url/url_search_params.rs — a non-string, non-object init. Upstream ignores it, so new URLSearchParams(null) and new URLSearchParams(42) both build an EMPTY query. WebIDL's init union is not nullable, so anything that is neither a sequence nor a record converts to USVString: the queries are null and 42, which is what every browser engine produces. Only undefined (the argument omitted) means empty.

  5. encoding/mod.rswindows-1252 / latin1 / ascii are real. Upstream folds Windows1252 into the UTF-8 arm in every direction, so new TextDecoder('windows-1252').decode([0xE9]) answered U+FFFD and Buffer.from('é', 'latin1') produced the two UTF-8 bytes rather than one. Worse, one label map served both consumers, which cannot be right: Node's latin1 is ISO-8859-1 and its ascii masks the high bit, while the WHATWG Encoding Standard maps BOTH labels to windows-1252. There are now two maps — Encoder::from_str for Buffer, Encoder::from_web_label for TextDecoder — and three single-byte variants (Windows1252 with the real 0x80-0x9F index, Latin1, Ascii) implemented in both directions.

  6. url/mod.rsfileURLToPath decodes and validates. Upstream strips the file:// prefix and hands the rest to PathBuf: the scheme is never checked, a host is silently swallowed, a query or fragment stays in the path, and percent-escapes are NOT decoded, so file:///tmp/a%20b.txt names a file whose name literally contains %20. Node checks the scheme, refuses a host it cannot address locally, drops query and fragment, decodes the escapes and refuses an ENCODED separator (which would otherwise change which file is named).

  7. url/url_class.rsurlToHttpOptions matches Node's shape. Upstream reports port as a STRING, omits search / hash when they are empty, keeps the brackets on an IPv6 hostname, and joins the raw percent-encoded credentials into auth. Node reports a numeric port, always sets search and hash, hands over a bare IPv6 host (what a socket connect takes) and decodeURIComponents the credentials. URL::inner_url was upstream's only reader for the omitted-hash branch and goes with it; the percent-encoding dep is for the credential decode (url keeps that crate private).

  8. fs/mod.rsexistsSync. Node has it and a large share of real code calls it; upstream ships neither it nor a callback API, so without it the only way to ask whether a file is there is to catch a stat rejection.

  9. fs/mod.rs — one namespace per VM. Upstream builds a fresh exports object per module evaluation. Node answers the SAME object for require("fs"), require("node:fs") and fs.promises vs require("fs/promises"), so the namespaces are built once per context and remembered; the fs global is that object too. Without it, identity comparisons are false and a caller who patches a method patches a copy nobody else sees.

  10. zlib — the codec back-ends taken. Upstream's default is compression-c, which links a system zlib-ng, brotli and zstd. This crate takes the pure-Rust back-end wherever one exists (flate2/rust_backend, which is also what CompressionStream already used, and the brotli crate) and zstd-c for zstd, which upstream's own manifest says has no pure-Rust implementation. The zstd crate vendors and compiles the C rather than needing one installed, so the build still has no system dependency — the same posture as rquickjs-sys compiling QuickJS. All six upstream feature names are declared, so its #[cfg] arms stay exactly as written.

  11. compression/streaming.rs is not vendored. Its only consumer upstream is llrt_fetch's response decoder, and this crate does not vendor llrt_fetch (ferridriver's fetch is its own, over reqwest). Carrying it would be dead code that also trips clippy::large_enum_variantStreamingDecoder's zstd variant is ~336 bytes larger than the next — and boxing a variant to satisfy a lint in code nothing calls is worse than not taking the file. Same reasoning as buffer/blob.rs, crypto/provider/ring.rs and os/windows.rs.

  12. zlib/codec.rs and string_decoder/decoder.rs are upstream's zlib.rs and string_decoder.rs, renamed. A module with its parent's name trips clippy::module_inception, which is on by default and the repo's gate runs -D warnings. Same fix as delta 14.

  13. zlib/{brotli,codec,zstd}.rs — macro imports. define_sync_function and define_cb_function are #[macro_export]ed, so they live at the crate root rather than under zlib. The use super::{...} lines are split: the two macros come from crate::, the plain items still from super::. Same cause as delta 18.

  14. perf_hooks — the module only, not the global. Upstream's init installs its own Performance class on globalThis, and taking it would be a regression: Performance::now reads llrt_utils::time::now_nanos, which is SystemTime::now() — the WALL clock. High Resolution Time exists to give a monotonic reading, so upstream's now() steps backwards whenever the system clock does, and saturating_sub clamps that to 0 rather than surfacing it. web::init's reads Instant::elapsed, which is monotonic by construction. Upstream also leaves origin_nanos() at 0 until a host calls time::init(), and an unset origin makes now() return the whole Unix epoch in milliseconds.

    Upstream's MODULE body only reads globalThis.performance and re-exports it, so dropping init (and performance.rs with it) leaves perf_hooks serving ferridriver's own.

    The two things upstream's class had over the old plain object — toJSON() and being a real class instance — are in web::performance now, along with the User Timing and Performance Timeline surface neither side had. See "performance" below.

  15. navigator — this runtime's name. Upstream hardcodes userAgent to llrt <version>. Shipping that verbatim would answer every user-agent sniffer with the wrong runtime. It reads ferridriver/<version> here, which is the shape Node 21+ uses.

    Worth knowing before relying on it: a navigator global is still how some libraries decide they are in a browser, the mirror image of the process.versions.node check. It is Node parity, not a browser claim, but a package that misroutes on it is misrouting for this reason.

  16. stream/web registered as a specifier. The implementation was vendored from the start but only ever installed as globals, so import { ReadableStream } from 'node:stream/web' — Node's own name for it, and how a library reaches it without assuming a browser — resolved to nothing. modules.rs now serves it, reading the classes back off the globals so there is still one implementation.

performance

web::performance is ferridriver's, not vendored, and covers three specs rather than the now() / timeOrigin pair it started as:

  • High Resolution Timenow(), timeOrigin, toJSON(). now() reads Instant::elapsed; the wall clock appears exactly once, as timeOrigin, which is what the monotonic readings are relative to.
  • User Timingmark(), measure(), clearMarks(), clearMeasures(), and the PerformanceMark / PerformanceMeasure classes with their detail. measure implements all three overloads (bare name, start-mark, options bag) and refuses the two combinations the spec calls out: an options bag together with a trailing endMark, and start + end + duration all at once.
  • Performance TimelinegetEntries(), getEntriesByName(), getEntriesByType(), sorted chronologically by startTime rather than by insertion, because mark(name, { startTime }) can backdate an entry. The sort is stable, so entries sharing a startTime keep the order they were recorded in.

PerformanceMark and PerformanceMeasure chain their prototype to PerformanceEntry, so mark instanceof PerformanceEntry holds. Constructibility follows the IDL: PerformanceMark takes (name, options) and does NOT buffer (only performance.mark() records), while PerformanceEntry, PerformanceMeasure and Performance throw Illegal constructor.

performance.now() and process.hrtime() count from ONE base (performance::monotonic_base), so the two are correlatable the way Node's are — it derives both from a single libuv hrtime. Two separate Instant::now() calls would put a constant, invisible skew between them; a test asserts they agree within a millisecond.

Not implemented: PerformanceObserver (it needs a task-queue hook this runtime has no equivalent of), the resource and navigation entry types (no document), Node's eventLoopUtilization / nodeTiming, and a buffer size limit — nothing evicts, so a program marking in a hot loop grows the buffer until it calls clearMarks.

Intl is absent, and llrt cannot fill it

QuickJS-ng as vendored by rquickjs-sys contains no ECMA-402 at all — no Intl object, and no build flag that would add one. react-intl and anything like it fails with Intl is not defined.

llrt_intl does not close this. It is Intl.DateTimeFormat plus supportedValuesOf and Date.prototype.toLocaleString, aimed at timezone support, and it carries a jiff dependency and ~3k lines of bundled CLDR data. There is no NumberFormat, PluralRules or Collator — which is the part a formatting library actually reaches for.

Until that changes, a suite that needs Intl supplies a JS polyfill through [bundler.alias], which keeps the choice (and its weight) in the extension that needs it.

  1. crypto/subtle/digest.rs — a synchronous validation failure is kept. Upstream validates the algorithm before returning the future, so the exception is thrown while subtle_digest is still returning Ok(future); by the time the future runs, the pending exception is gone and the promise rejects with an uninitialized value (typeof e === "unknown"). The thrown value is now taken with ctx.catch() at that point and re-thrown inside the future, where the rejection is built. Upstream candidate.

  2. fs/access.rs and fs/stats.rs — preserve filesystem error identity. Sync and async access, stat, and lstat failures use node::system_error to retain code, errno, syscall, and path. Previously every metadata failure became a plain missing-file message, preventing callers from distinguishing missing paths from permission or directory errors.

  3. fs/read_file.rs — preserve read and open errors. Sync and async reads retain code, errno, syscall, and path through node::system_error, including EISDIR for reading a directory.

  4. crypto/provider/rust/mod.rs — RSA is ours, not upstream's rsa crate. Upstream's pure-Rust provider implements RSA with the rsa crate, which carries RUSTSEC-2023-0071 (Marvin attack: key recovery through a timing side channel in a non-constant-time implementation). RustSec records patched = [], so there is no version to move to and no consumer can resolve it by bumping. A re-sync diffs against upstream and would take it back, so this entry exists to stop that.

    Where RSA went is delta 37, along with the rest of the provider.

  5. The crypto provider is AWS-LC, not RustCrypto. This is the most important entry in this list: upstream's provider is a pile of RustCrypto crates, so a re-sync diffs against that and would drag all of them back. crypto/provider/rust/mod.rs keeps its upstream name and its crypto-rust feature gate, so upstream's #[cfg] arms still line up. The implementation underneath is aws-lc-rs.

    That covers digests, HMAC, HKDF, PBKDF2, all of RSA, ECDSA, ECDH, EC key generation and the SEC1 / SPKI / PKCS#8 / JWK conversions, Ed25519, X25519, AES-CBC, and in provider/modern.rs ChaCha20-Poly1305, SHA-3 and the traditional half of the hybrid KEMs.

    A host that speaks TLS already links the same aws-lc-sys through rustls, so this puts one C crypto library in the binary rather than a second one beside it. That is the reason it is not the openssl crate: an earlier version of this provider vendored OpenSSL, which built a whole second library from source and wanted perl and make that nothing else here needs. openssl-sys can be backed by aws-lc-sys and would have kept more surface, but it pins that dependency to 0.41 where rustls is on 0.45, and the differing link names mean the binary would hold two copies of AWS-LC.

    What stays on pure-Rust crates is what AWS-LC's safe API cannot reach. They add no C build and cross-compile wherever this runtime does. md-5 is there because AWS-LC has no MD5, and cshake, keccak and sponge-cursor sit behind CSHAKE and TurboSHAKE. shake is there because AWS-LC implements SHA-3 but exposes no XOF, and the hybrid KEM seed expansion needs SHAKE-256. ml-kem and ml-dsa keep their private keys as seeds, where AWS-LC takes only expanded ones. And aes-gcm carries the seven WebCrypto tag lengths AWS-LC cannot express, its AEAD being fixed at sixteen bytes.

    Three capabilities are refused rather than answered wrongly, and each has a test pinning the refusal. RSA-PSS takes only a saltLength equal to the digest length. RSA generates only at AWS-LC's four sizes and only with e = 65537. ECDSA signs only under the hash its curve is paired with, which is ES256, ES384 and ES512.

    Four things do not survive a careless re-sync, and each has a test that fails if it is lost. WebCrypto's AES-CTR length is the width of the counter field and wraps inside it, while AWS-LC's CTR always increments the full 128-bit block, so the keystream is built from AES-ECB. AES-KW is RFC 3394 over the same ECB primitive, because aws_lc_rs::key_wrap carries no 192-bit algorithm. EC coordinates are left-padded to the field width, 66 bytes on P-521, and an ECDSA signature is the fixed-width r and s. And a hybrid KEM private key is a seed expanded with SHAKE-256 whose traditional scalar is the first chunk AWS-LC accepts as a valid one.

    On re-sync: keep OUR provider. Do not take upstream's RustCrypto one back.