diff --git a/.mise/config.toml b/.mise/config.toml index b9b0b595..4aa3f8c5 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -878,6 +878,7 @@ depends = [ "actionlint-check", "agent-lint-check", "ast-grep-check", + "ast-grep-check-rule-tests", "branch-up-to-date-check", "cargo-check", "cargo-clippy-check", @@ -905,6 +906,7 @@ depends = [ "repo-check", "ryl-check", "semgrep-check", + "semgrep-check-rule-tests", "shellcheck-check", "shellcheck-mise-check", "shfmt-mise-check", @@ -1021,32 +1023,42 @@ echo "macos-sdk-check: SDK $sdk (minimum $min)" shell = "{{ vars.task_shell }}" [tasks.ast-grep-check] -depends = ["python-safety-rules-test"] +depends = ["ast-grep-check-rule-tests"] # --no-ignore hidden so the gha-* YAML rules reach .github/workflows. # ast-grep skips dot-dirs by default; gitignored paths like target/ stay skipped. description = "Run ast-grep structural-search rules" run = "ast-grep scan --error --no-ignore hidden -c config/ast-grep/sgconfig.yaml" -[tasks.python-safety-rules-test] -description = "Test Python exception safety rules against unsafe and intentional recovery handlers" -run = """ -ast-grep test -c config/ast-grep/sgconfig.yaml --skip-snapshot-tests -work=target/scratch/python-safety-rules -coreutils mkdir -p "$work" -yq -r '.code' config/semgrep-tests/python-exception-safety.yaml >"$work/python-exception-safety.py" -semgrep --test --metrics=off --config config/semgrep/python-exception-safety.yaml "$work/python-exception-safety.py" -""" -shell = "{{ vars.task_shell }}" +# The valid/invalid cases in config/ast-grep/tests, one file per rule id. +[tasks.ast-grep-check-rule-tests] +description = "Run the ast-grep rule tests against their valid and invalid cases (ast-grep test)" +run = "ast-grep test -c config/ast-grep/sgconfig.yaml --skip-snapshot-tests" [tasks.ast-grep-fix] description = "Apply autofix for ast-grep rules that define a `fix:` field" run = "ast-grep scan --update-all --no-ignore hidden -c config/ast-grep/sgconfig.yaml" [tasks.semgrep-check] -depends = ["python-safety-rules-test"] +depends = ["semgrep-check-rule-tests"] description = "Run Semgrep code and configuration rules" run = "semgrep scan --config config/semgrep --error --metrics=off ." +# Each case file in config/semgrep-tests is `..yaml`, its annotated target under `code:`. +# The target is wrapped in YAML so the repo carries no loose source file in that language; it is unwrapped into +# target/ under its real name, where `semgrep --test` pairs it with the rule file of the same stem. +[tasks.semgrep-check-rule-tests] +description = "Run the Semgrep rule tests against their annotated cases (semgrep --test)" +run = """ +work=target/scratch/semgrep-rule-tests +coreutils rm -rf "$work" +coreutils mkdir -p "$work" +for case in config/semgrep-tests/*.yaml; do + yq -r '.code' "$case" >"$work/$(coreutils basename "$case" .yaml)" +done +semgrep --test --metrics=off --config config/semgrep "$work" +""" +shell = "{{ vars.task_shell }}" + [tasks.semgrep-fix] description = "Apply autofix for Semgrep rules that define a `fix:` field" run = "semgrep scan --config config/semgrep --autofix --metrics=off ." diff --git a/AGENTS.md b/AGENTS.md index 8fc5ecb9..fc002e5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1249,6 +1249,17 @@ When the rewrite can't be expressed as a single template (multiple match shapes, structural restructuring), keep the rule check-only and write a brief note in the rule body explaining why the autofix wasn't viable. +### When you write or change an ast-grep or semgrep rule, give it tests + +A new ast-grep or semgrep rule lands with its test cases in the same change, and a modified rule lands with cases +covering the shapes the modification added or stopped matching -- just as a conftest policy carries its +`_test.rego` beside it. ast-grep cases go in `config/ast-grep/tests/.yaml` as `valid:` / `invalid:` +snippets; semgrep cases go in `config/semgrep-tests/` as annotated targets. `ast-grep-check-rule-tests` and +`semgrep-check-rule-tests` run them. Cover both directions: at least one snippet the rule must flag, and the +nearest legitimate code it must leave alone, since a pattern that over-matches fails the repo far more often +than one that under-matches. Confirm a new case can fail -- run the tool against a broken copy under +`target/scratch/` and watch it go red -- because a case the rule never pairs with passes silently. + ### NEVER delete a lint rule without explicit user permission **Do not delete a rule from `config/ast-grep/rules/`, `config/semgrep/`, `config/conftest/policy/`, `config/taplo/`, diff --git a/CHECKS.md b/CHECKS.md index b3b0426d..755805d0 100644 --- a/CHECKS.md +++ b/CHECKS.md @@ -1,10 +1,10 @@ # Checks -- **Checks:** 72 +- **Checks:** 74 - **Tools:** 47 - **Shell-script checks:** 9 - **Checks with a repair pass:** 21 -- **Rules:** 339 custom local rules, or non-default strict linter settings +- **Rules:** 340 custom local rules, or non-default strict linter settings Every check is listed across every `MISE_ENV`, filed under the tool it runs. Each entry names the env whose config declares it, the aggregate that runs it as part of `mise run check`, the `fix` or `fmt` pass that repairs what it @@ -31,6 +31,8 @@ gen:checks` from the task tables in `.mise/config*.toml`, the rule files under ` - `ast-grep-check` (`default` env; run by `check:rust`) -- Run ast-grep structural-search rules. Repaired by `ast-grep-fix`. Config: `config/ast-grep/sgconfig.yaml`. +- `ast-grep-check-rule-tests` (`default` env; run by `check:rust`) -- Run the ast-grep rule tests against their valid + and invalid cases (ast-grep test). Config: `config/ast-grep/sgconfig.yaml`. Rules, from `config/ast-grep/rules`: @@ -230,6 +232,9 @@ Rules, from `config/ast-grep/rules`: - `no-string-new` -- `String::new()` is banned (`DeepSource RS-W1079`): construct empty strings with `String::default()` instead, so the zero value routes through the `Default` impl like every other default-constructed value in the codebase. +- `no-uds-listener` -- HTTP servers here listen on TCP only. A Unix-domain-socket listener gives requests no peer + address, and the handlers take the client address from the socket peer rather than from client-supplied `Forwarded` / + `X-Forwarded-For` headers -- so `ws_handler` refuses every request arriving on one. Bind a TCP address instead. - `org-identity-lives-in-et-org` -- This constant spells out the organisation name or the crate prefix. Both belong to `et-org`, which is the one place either literal is written: take the value from `et_org::ORG`, `et_org::CRATE_PREFIX`, `et_org::NPM_SCOPE`, `et_org::REPOSITORY_URL` or `et_org::IMAGE_REGISTRY`, or build a longer string from them, rather @@ -765,6 +770,8 @@ Rules, from `config/conftest/policy`: - `semgrep-check` (`default` env; run by `check:rust`) -- Run Semgrep code and configuration rules. Repaired by `semgrep-fix`. Config: `.semgrepignore`, `config/semgrep`. +- `semgrep-check-rule-tests` (`default` env; run by `check:rust`) -- Run the Semgrep rule tests against their annotated + cases (semgrep --test). Config: `.semgrepignore`, `config/semgrep`, `config/semgrep-tests`. Rules, from `config/semgrep`: diff --git a/Cargo.lock b/Cargo.lock index 0653921d..182e57e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4991,6 +4991,7 @@ dependencies = [ name = "et-ws-service" version = "0.2.0" dependencies = [ + "actix-rt", "actix-web", "actix-ws", "bytes", diff --git a/README.md b/README.md index d63bb427..bd4cffb1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [coverage]: https://codecov.io/gh/edge-toolkit/core [crate-badge]: https://img.shields.io/crates/v/et-ws-server [crate]: https://crates.io/crates/et-ws-server -[checks-badge]: https://img.shields.io/badge/checks-%3E%3D320-blue +[checks-badge]: https://img.shields.io/badge/checks-%3E%3D340-blue [checks]: CHECKS.md [license-badge]: https://img.shields.io/badge/license-MIT%2BApache--2.0-blue [license]: #licence diff --git a/config/ast-grep/rules/no-uds-listener.yaml b/config/ast-grep/rules/no-uds-listener.yaml new file mode 100644 index 00000000..5aae5307 --- /dev/null +++ b/config/ast-grep/rules/no-uds-listener.yaml @@ -0,0 +1,12 @@ +id: no-uds-listener +language: Rust +severity: error +message: | + HTTP servers here listen on TCP only. A Unix-domain-socket listener gives requests no peer address, and the + handlers take the client address from the socket peer rather than from client-supplied `Forwarded` / + `X-Forwarded-For` headers -- so `ws_handler` refuses every request arriving on one. Bind a TCP address instead. +rule: + pattern: $SERVER.$METHOD($$$ARGS) +constraints: + METHOD: + regex: ^(bind_uds|listen_uds)$ diff --git a/config/ast-grep/tests/no-uds-listener.yaml b/config/ast-grep/tests/no-uds-listener.yaml new file mode 100644 index 00000000..d268bca3 --- /dev/null +++ b/config/ast-grep/tests/no-uds-listener.yaml @@ -0,0 +1,19 @@ +id: no-uds-listener +valid: + - | + fn serve() { + let _server = HttpServer::new(app).bind(("127.0.0.1", 8080)); + } + - | + fn serve(listener: std::net::TcpListener) { + let _server = HttpServer::new(app).listen(listener); + } +invalid: + - | + fn serve() { + let _server = HttpServer::new(app).bind_uds("/run/et.sock"); + } + - | + fn serve(listener: std::os::unix::net::UnixListener) { + let _server = HttpServer::new(app).listen_uds(listener); + } diff --git a/config/jscpd-baseline.json b/config/jscpd-baseline.json index 3a2a90f2..6e83ca18 100644 --- a/config/jscpd-baseline.json +++ b/config/jscpd-baseline.json @@ -310,7 +310,6 @@ "f3eba887cab8ffc1": 1, "f45581aa7a8806a0": 1, "f5104a6be7ba929d": 1, - "f538dacb09de8e14": 1, "f773b3122d6cb21a": 1, "f8dacf11c2b785d4": 1, "f964c720cdcd7ac0": 1, @@ -321,6 +320,7 @@ "fb78fa42d28ddc83": 1, "fb8747afe01f9463": 1, "fb8c4a24a3cbfb47": 1, + "fcad2abbc735eba6": 1, "ff0bc219f7f029b9": 1 } } diff --git a/config/semgrep-tests/python-exception-safety.yaml b/config/semgrep-tests/python-exception-safety.py.yaml similarity index 100% rename from config/semgrep-tests/python-exception-safety.yaml rename to config/semgrep-tests/python-exception-safety.py.yaml diff --git a/generated/dart-ws/lib/ws_messages.dart b/generated/dart-ws/lib/ws_messages.dart index ed8971e8..4e6a415f 100644 --- a/generated/dart-ws/lib/ws_messages.dart +++ b/generated/dart-ws/lib/ws_messages.dart @@ -1,27 +1,27 @@ // ignore_for_file: unnecessary_cast final class AgentSummary { final String agentId; - final String? lastKnownIp; + final String lastKnownIp; final AgentConnectionState state; const AgentSummary({ required this.agentId, - this.lastKnownIp, + required this.lastKnownIp, required this.state, }); static AgentSummaryBuilder builder({ required String agentId, - String? lastKnownIp, + required String lastKnownIp, required AgentConnectionState state, }) => AgentSummaryBuilder( agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + lastKnownIp: lastKnownIp, state: state, ); AgentSummaryBuilder toBuilder() => AgentSummaryBuilder( agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), + lastKnownIp: lastKnownIp, state: state, ); @@ -32,11 +32,7 @@ final class AgentSummary { }; factory AgentSummary.fromJson(Map json) => AgentSummary( agentId: json["agent_id"] as String, - lastKnownIp: json["last_known_ip"] == null - ? null - : json["last_known_ip"] == null - ? null - : json["last_known_ip"] as String, + lastKnownIp: json["last_known_ip"] as String, state: AgentConnectionState.fromJson(json["state"]), ); @@ -69,13 +65,13 @@ final class AgentSummary { @override int get hashCode => - Object.hashAll([agentId.hashCode, lastKnownIp?.hashCode, state.hashCode]); + Object.hashAll([agentId.hashCode, lastKnownIp.hashCode, state.hashCode]); } /// Builder class for [AgentSummary] final class AgentSummaryBuilder { String agentId; - String? lastKnownIp; + String lastKnownIp; AgentConnectionState state; AgentSummaryBuilder({ @@ -84,11 +80,8 @@ final class AgentSummaryBuilder { required this.state, }); - AgentSummary build() => AgentSummary( - agentId: agentId, - lastKnownIp: lastKnownIp == null ? null : (lastKnownIp as String), - state: state, - ); + AgentSummary build() => + AgentSummary(agentId: agentId, lastKnownIp: lastKnownIp, state: state); } final class CloudEvent { diff --git a/generated/specs/wit/deps/et-ws-messages/messages.wit b/generated/specs/wit/deps/et-ws-messages/messages.wit index 3622a2fd..be1483bc 100644 --- a/generated/specs/wit/deps/et-ws-messages/messages.wit +++ b/generated/specs/wit/deps/et-ws-messages/messages.wit @@ -21,9 +21,14 @@ interface messages { broadcast, } type spec-version = string; + /// An IPv4 or IPv6 address, as a fixed-size tuple of its octets or hextets. + variant ip-address { + ipv4(tuple), + ipv6(tuple), + } record agent-summary { agent-id: string, - last-known-ip: option, + last-known-ip: ip-address, state: agent-connection-state, } record cloud-event { diff --git a/generated/specs/ws.kdl b/generated/specs/ws.kdl index 80a4c0a0..630b15f8 100644 --- a/generated/specs/ws.kdl +++ b/generated/specs/ws.kdl @@ -32,9 +32,7 @@ enum "SpecVersion" { } class "AgentSummary" { field "agentId" type="String" - field "lastKnownIp" type="String?" { - defaults-to null - } + field "lastKnownIp" type="String" field "state" type="AgentConnectionState" } class "CloudEvent" { diff --git a/generated/specs/ws.yaml b/generated/specs/ws.yaml index c2593dce..a7d418a4 100644 --- a/generated/specs/ws.yaml +++ b/generated/specs/ws.yaml @@ -290,14 +290,14 @@ components: agent_id: type: string last_known_ip: - type: - - string - - "null" + format: ip + type: string state: $ref: "#/components/schemas/AgentConnectionState" required: - agent_id - state + - last_known_ip type: object CloudEvent: description: |- diff --git a/libs/edge-toolkit/src/ws.rs b/libs/edge-toolkit/src/ws.rs index 9bd5d530..1d501185 100644 --- a/libs/edge-toolkit/src/ws.rs +++ b/libs/edge-toolkit/src/ws.rs @@ -1,3 +1,5 @@ +use std::net::IpAddr; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -95,12 +97,12 @@ pub enum AgentConnectionState { pub struct AgentSummary { pub agent_id: String, pub state: AgentConnectionState, - pub last_known_ip: Option, + pub last_known_ip: IpAddr, } impl AgentSummary { #[must_use] - pub const fn new(agent_id: String, state: AgentConnectionState, last_known_ip: Option) -> Self { + pub const fn new(agent_id: String, state: AgentConnectionState, last_known_ip: IpAddr) -> Self { Self { agent_id, state, diff --git a/libs/edge-toolkit/src/ws_server.rs b/libs/edge-toolkit/src/ws_server.rs index 1e8a9f54..cee8e5a1 100644 --- a/libs/edge-toolkit/src/ws_server.rs +++ b/libs/edge-toolkit/src/ws_server.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::net::IpAddr; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use serde::{Deserialize, Serialize}; @@ -90,7 +91,7 @@ pub struct PendingDirectMessage { #[non_exhaustive] pub struct AgentRecord { pub state: AgentConnectionState, - pub last_known_ip: Option, + pub last_known_ip: IpAddr, #[serde(skip)] pub session: Option, #[serde(default)] @@ -100,7 +101,7 @@ pub struct AgentRecord { impl AgentRecord { /// Construct a record with no pending direct messages. #[must_use] - pub const fn new(state: AgentConnectionState, last_known_ip: Option, session: Option) -> Self { + pub const fn new(state: AgentConnectionState, last_known_ip: IpAddr, session: Option) -> Self { Self { state, last_known_ip, @@ -165,7 +166,7 @@ impl AgentRegistry { &self, requested_id: Option, new_id: String, - client_ip: &str, + client_ip: IpAddr, session: S, ) -> (String, ConnectStatus) { let mut agents = lock_agents(&self.agents); @@ -174,7 +175,7 @@ impl AgentRegistry { && let Some(record) = agents.get_mut(requested) { record.state = AgentConnectionState::Connected; - record.last_known_ip = Some(client_ip.to_string()); + record.last_known_ip = client_ip; record.session = Some(session); return (requested.to_string(), ConnectStatus::Reconnected); } @@ -187,7 +188,7 @@ impl AgentRegistry { assigned_id.clone(), AgentRecord { state: AgentConnectionState::Connected, - last_known_ip: Some(client_ip.to_string()), + last_known_ip: client_ip, session: Some(session), pending_direct_messages: BTreeMap::new(), }, @@ -231,7 +232,7 @@ impl AgentRegistry { .map(|(agent_id, record)| AgentSummary { agent_id: agent_id.clone(), state: record.state.clone(), - last_known_ip: record.last_known_ip.clone(), + last_known_ip: record.last_known_ip, }) .collect::>(); drop(agents); diff --git a/libs/edge-toolkit/tests/registry.rs b/libs/edge-toolkit/tests/registry.rs index 699fdc3e..8f504dae 100644 --- a/libs/edge-toolkit/tests/registry.rs +++ b/libs/edge-toolkit/tests/registry.rs @@ -5,21 +5,25 @@ #![cfg(test)] use std::collections::BTreeMap; +use std::net::{IpAddr, Ipv4Addr}; use edge_toolkit::ws::{AgentConnectionState, ConnectStatus}; use edge_toolkit::ws_server::{AgentRecord, AgentRegistry, MAX_AGENT_ID_LEN, is_valid_agent_id}; use tempfile::tempdir; +const FIRST_IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); +const SECOND_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)); + #[test] fn save_load_roundtrip_and_session_lookup() { let registry = AgentRegistry::::default(); // Fresh connection (Assigned), then a reconnect for the same id which swaps in a new session. - let (agent_id, _assigned) = registry.connect_agent(None, "agent-1".to_string(), "127.0.0.1", "sess-a".to_string()); + let (agent_id, _assigned) = registry.connect_agent(None, "agent-1".to_string(), FIRST_IP, "sess-a".to_string()); let (_same_id, _reconnected) = registry.connect_agent( Some(agent_id.clone()), "agent-x".to_string(), - "127.0.0.2", + SECOND_IP, "sess-b".to_string(), ); @@ -36,6 +40,7 @@ fn save_load_roundtrip_and_session_lookup() { registry.save(&path).unwrap(); let reloaded = AgentRegistry::::load(&path).unwrap(); assert_eq!(reloaded.agent_session(&agent_id), None, "sessions are not persisted"); + assert_eq!(reloaded.list_agents()[0].last_known_ip, SECOND_IP); // load() on a missing file yields an empty registry rather than erroring. let empty = AgentRegistry::::load(&dir.path().join("absent.yaml")).unwrap(); @@ -51,7 +56,7 @@ fn an_unsafe_id_is_replaced_and_an_unknown_disconnect_is_a_no_op() { let (agent_id, status) = registry.connect_agent( Some("peer/escape".to_string()), "agent-fresh".to_string(), - "127.0.0.1", + FIRST_IP, "sess-a".to_string(), ); assert_eq!(agent_id, "agent-fresh"); @@ -96,7 +101,7 @@ fn an_unknown_valid_id_is_adopted_as_the_agents_own() { let (agent_id, status) = registry.connect_agent( Some("sensor-7".to_string()), "agent-generated".to_string(), - "127.0.0.1", + FIRST_IP, "sess-a".to_string(), ); assert_eq!(agent_id, "sensor-7"); @@ -111,14 +116,14 @@ fn a_displaced_session_cannot_disconnect_the_one_that_replaced_it() { let (agent_id, _assigned) = registry.connect_agent( Some("shared".to_string()), "unused".to_string(), - "127.0.0.1", + FIRST_IP, "sess-a".to_string(), ); // A second connection claims the same id while the first still holds it, and takes it over. let (_same_id, status) = registry.connect_agent( Some(agent_id.clone()), "unused".to_string(), - "127.0.0.2", + SECOND_IP, "sess-b".to_string(), ); assert_eq!(status, ConnectStatus::Reconnected); @@ -140,7 +145,7 @@ fn a_record_with_no_session_is_disconnected_whatever_the_test_says() { let dir = tempdir().unwrap(); let path = dir.path().join("registry.yaml"); let registry = AgentRegistry::::default(); - let (agent_id, _assigned) = registry.connect_agent(None, "agent-1".to_string(), "127.0.0.1", "sess-a".to_string()); + let (agent_id, _assigned) = registry.connect_agent(None, "agent-1".to_string(), FIRST_IP, "sess-a".to_string()); registry.save(&path).unwrap(); let reloaded = AgentRegistry::::load(&path).unwrap(); @@ -165,7 +170,7 @@ fn agent_ids_are_held_to_a_single_safe_path_segment() { #[test] fn agent_record_with_pending_builder_replaces_the_map() { - let record = AgentRecord::::new(AgentConnectionState::Disconnected, None, None) + let record = AgentRecord::::new(AgentConnectionState::Disconnected, FIRST_IP, None) .with_pending_direct_messages(BTreeMap::new()); assert!(record.pending_direct_messages.is_empty()); } diff --git a/services/storage/tests/put.rs b/services/storage/tests/put.rs index 27e2648e..5a6387ad 100644 --- a/services/storage/tests/put.rs +++ b/services/storage/tests/put.rs @@ -10,6 +10,7 @@ #![cfg(test)] use std::collections::BTreeMap; +use std::net::Ipv4Addr; use actix_web::dev::Payload as DevPayload; use actix_web::error::ResponseError as _; @@ -25,7 +26,7 @@ fn registry_with_agent(agent_id: &str) -> AgentRegistry<()> { let mut agents = BTreeMap::new(); let _previous: Option> = agents.insert( agent_id.to_string(), - AgentRecord::new(AgentConnectionState::Connected, None, Some(())), + AgentRecord::new(AgentConnectionState::Connected, Ipv4Addr::LOCALHOST.into(), Some(())), ); AgentRegistry::from_agents(agents) } diff --git a/services/storage/tests/s3_backend.rs b/services/storage/tests/s3_backend.rs index 2ffba34e..068d8f2b 100644 --- a/services/storage/tests/s3_backend.rs +++ b/services/storage/tests/s3_backend.rs @@ -21,6 +21,7 @@ #![cfg(test)] use std::collections::BTreeMap; +use std::net::Ipv4Addr; use std::path::Path; use std::process::{Command, Stdio}; @@ -53,7 +54,7 @@ fn registry_with_agent(agent_id: &str) -> AgentRegistry<()> { let mut agents = BTreeMap::new(); let _inserted = agents.insert( agent_id.to_string(), - AgentRecord::new(AgentConnectionState::Connected, None, Some(())), + AgentRecord::new(AgentConnectionState::Connected, Ipv4Addr::LOCALHOST.into(), Some(())), ); AgentRegistry::from_agents(agents) } diff --git a/services/ws-modules/pydata1/pkg/et_ws_pydata1.js b/services/ws-modules/pydata1/pkg/et_ws_pydata1.js index ec0a646b..42d6e047 100644 --- a/services/ws-modules/pydata1/pkg/et_ws_pydata1.js +++ b/services/ws-modules/pydata1/pkg/et_ws_pydata1.js @@ -2,6 +2,9 @@ // Interface: default(wasmUrl), metadata(), run() const PYODIDE_BASE_PATH = "/modules/pyodide/"; +// ES-module entry used by the Deno / non-browser `import()` path below (its named export is `loadPyodide`); +// the browser path loads the UMD `pyodide.js` via a