Skip to content

feat(plugin-sdk): bind host imports + permission gates (phase 2b)#222

Open
InstaZDLL wants to merge 8 commits into
mainfrom
feat/plugin-sdk-phase-2b
Open

feat(plugin-sdk): bind host imports + permission gates (phase 2b)#222
InstaZDLL wants to merge 8 commits into
mainfrom
feat/plugin-sdk-phase-2b

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 2b of RFC-002. Lights up the wasm side of the SDK: the wasmtime bindgen! macro now consumes the waveflow:source/plugin world, and the host implements the three waveflow:host/* interfaces it imports — HTTP, log, storage — against HostCtx. Permission decisions happen on every call against a snapshot taken at instantiate time, so a manifest swapped on disk mid-session can't sneak new permissions in until the host reloads.

What lands

  • plugin::bindings::sourcebindgen! invocation pointed at crates/plugin-sdk/wit/source/. Trappable imports so Host methods can return `wasmtime::Result<...>`.
  • plugin::host_impl:
    • `HostPermissions` — HTTP allowlist compiled into a `globset::GlobSet` at instantiate time + storage flags.
    • `StateStore` — FS-backed scratch (SHA-256(key) filenames) with the 10 MB quota the WIT promises. Honoured at write time, skips the file being overwritten so an in-place same-size update doesn't always fail at usage ≈ quota.
    • Host impls for http / log / storage. Permission denials return `Ok(Err("permission denied: ..."))` so the guest sees a clean WIT error string — no trap.
    • `add_to_linker(linker)` — one call registers all three imports. `HasSelf` turbofish picks the right `HasData` impl for the closure.
  • plugin::runtime:
    • `HostCtx` gains `plugin_id` / `permissions` / `assets` (Option) / `state` / `http_client`.
    • `PluginRuntime::new_store_for_plugin(&LoadedPlugin, &PluginPaths, plugin_id)` builds the full context.
    • `PluginRuntime::build_linker()` pre-registers every host import.

Sub-changes pulled along

  • `PluginPaths` splits the single `root` field into `plugins_root` + `data_root`. Scratch lives under `/waveflow/plugin-data//` so reinstalls don't risk clobbering user state. `state_dir(plugin_id)` shares the same id sanitisation as `plugin_dir`.
  • WIT layout restructured to per-world subdirs because `wit-parser` refuses multiple packages in one directory. `host.wit` is duplicated under each `/deps/host/` since cross-platform symlinks are a Windows pain.
  • `reqwest` gains the `blocking` feature — sync `http.send` signature is callable without bridging through tokio `block_on` inside a wasmtime callback (which would deadlock on the worker thread the guest runs on).
  • `globset = "0.4"` added for the HTTP allowlist matcher.

Permission contract

  • HTTP: empty manifest `http` list = no HTTP. Non-empty = compiled glob set; non-matching URL returns `Err("permission denied: ")`.
  • `storage.read` gates `read-asset`. No assets declared → returns `Err("plugin has no bundled assets")`.
  • `storage.state` gates `read-state` + `write-state` (one toggle covers both, per the WIT — a plugin that can write but not read is the same as one that writes into a key it then re-reads).
  • Quota overflow → `Err("storage quota exceeded: would use N bytes (cap M)")`.

Test plan

  • `cargo test --manifest-path src-tauri/Cargo.toml -p waveflow-core plugin::` — 30/30 pass (10 new in `host_impl::tests` + 1 new in `runtime::tests` for the linker)
    • `no_http_pattern_means_deny`
    • `http_allowlist_matches_glob_pattern`
    • `invalid_glob_pattern_surfaces_error`
    • `state_store_roundtrips_value`
    • `state_store_rejects_quota_overflow`
    • `state_store_overwrite_does_not_double_count` — in-place writes don't trip quota on themselves
    • `state_store_rejects_nul_key`
    • `state_dir_shares_sanitisation_with_plugin_dir` — `state_dir` rejects same id shapes `plugin_dir` does
    • `build_linker_succeeds` — full `add_to_linker` round-trip
  • `cargo check --manifest-path src-tauri/Cargo.toml --workspace --all-targets` — clean

What's intentionally NOT here

  • `metadata` + `ui` worlds: bindgen! only wired for `source/v1` since Web Radio (Phase 4) is the only Phase 4 consumer. Phase 5+ adds the other worlds when their first plugin lands.
  • Epoch ticker thread: `tick_epoch` is callable but the 10 ms tokio interval that drives it lives in Phase 3 (host wiring).
  • Tauri commands + Settings UI: Phase 3.
  • Web Radio plugin itself: Phase 4 — port `receiver-core` to wasm32-wasip2 with `receiver.db` (10 MB) as a sidecar asset.

Refs RFC-002 §6. Builds on #221.

Summary by CodeRabbit

  • Nouvelles Fonctionnalités

    • API hôte pour plugins : HTTP, journalisation et stockage d’état persistants (read/write, assets) avec contrat formel WIT.
    • Client HTTP synchrone intégré, probe "offline" renvoyant 503 pour appels locaux.
    • Liaison automatique des interfaces hôte pour les plugins (bindings/Linker).
  • Refactor

    • Runtime et contexte par-plugin (permissions, client HTTP partagé, store d’état) ; séparation claire racine d’installation vs racine de données.
  • Tests

    • Couverture étendue : allowlist HTTP, comportement offline, stockage (quota, concurrence, atomicité, validation).

… 2b)

Phase 2b lights up the wasm side of the SDK. The wasmtime
`bindgen!` macro now consumes `waveflow:source/plugin` (Phase 1's
declared world for sources like Web Radio) and the host implements
the three `waveflow:host/*` interfaces it imports — HTTP, log,
storage — against `HostCtx`. Permission decisions happen at every
call against a snapshot taken at instantiate time, so a manifest
swapped on disk mid-session can't sneak new permissions in until
the host reloads.

What lands:

- `plugin::bindings::source` — `bindgen!` invocation pointed at
  `crates/plugin-sdk/wit/source/`. Trappable imports so the Host
  trait methods can return `wasmtime::Result<...>`.
- `plugin::host_impl` — `HostPermissions` (allowlist compiled to a
  `GlobSet`, storage flags), `StateStore` (FS-backed scratch with
  the 10 MB quota the WIT promises), and Host impls for http /
  log / storage. `add_to_linker(linker)` registers every host
  binding in one call.
- `plugin::runtime` — `HostCtx` gains `plugin_id` / `permissions` /
  `assets` / `state` / `http_client`. `PluginRuntime::new_store_for_plugin`
  builds the full context from a `LoadedPlugin` + `PluginPaths`;
  `PluginRuntime::build_linker` pre-registers every host import.

Sub-changes pulled along:

- `PluginPaths` splits the single `root` field into `plugins_root`
  + `data_root`. The scratch store lives under
  `<app-data>/waveflow/plugin-data/<plugin-id>/` so reinstalls
  don't risk clobbering user state. `state_dir(plugin_id)` shares
  the same id sanitisation as `plugin_dir`.
- WIT layout restructured to per-world subdirs because wit-parser
  refuses multiple packages in one directory. `host.wit` is
  duplicated under each `<world>/deps/host/` since cross-platform
  symlinks are a Windows headache.
- `reqwest` gains the `blocking` feature so the sync `http.send`
  WIT signature is callable without bridging through a tokio
  block_on inside a wasmtime callback. `globset` added for the
  HTTP allowlist matcher.

10 new tests cover HTTP allowlist match/deny, glob compile errors,
state roundtrip, quota overflow, quota in-place overwrite, nul-key
rejection, and the linker build smoke test. 30/30 plugin tests
pass; workspace check clean.

Phase 3 will wire Tauri commands + the Settings → Plugins UI.
Phase 4 ports receiver-core as the first WASM plugin.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL InstaZDLL added scope: backend Rust/Tauri backend (src-tauri/) scope: deps Dependencies labels Jun 10, 2026
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f57944ea-8167-4839-a63b-12e3e06cc3ec

📥 Commits

Reviewing files that changed from the base of the PR and between 561f6bc and 05b17f0.

📒 Files selected for processing (2)
  • src-tauri/crates/core/src/plugin/host_impl.rs
  • src-tauri/crates/core/src/plugin/runtime.rs

📝 Walkthrough

Walkthrough

Implémentation des contrats WIT host (http/log/storage), bindings Wasmtime, implémentations hôtes avec permissions et StateStore persistant, refonte runtime par-plugin (Store/Linker/HostCtx) et activation de reqwest::blocking.

Changes

Plugin Host API and Runtime Integration

Layer / File(s) Summary
Host API contracts (HTTP, Log, Storage)
src-tauri/crates/plugin-sdk/wit/source/deps/host/host.wit, src-tauri/crates/plugin-sdk/wit/ui/deps/host/host.wit
Trois interfaces WIT waveflow:host@1.0.0 : HTTP (request/response/send), Log (level, emit), Storage (read-asset/read-state/write-state) avec contrats d'erreur/permission.
Plugin path management and directory separation
src-tauri/crates/core/src/plugin/mod.rs
PluginPaths passe à deux racines (plugins_root, data_root), from_app_data initialise les deux, plugin_dir et nouveau state_dir partagent la sanitisation; tests adaptés.
Wasmtime Component Model bindings
src-tauri/crates/core/src/plugin/bindings.rs
Module source généré par wasmtime::component::bindgen! pour waveflow:source/plugin avec imports trappables (propagation d'erreurs récupérables).
Dependency update
src-tauri/crates/core/Cargo.toml
Activation de la feature blocking pour reqwest (avec json et rustls-tls) pour permettre appels HTTP synchrones depuis le host.
Host permissions and persistent state storage
src-tauri/crates/core/src/plugin/host_impl.rs (définitions et StateStore)
HostPermissions compile une allowlist HTTP via GlobSet; HostError ajouté; StateStore persiste clé→fichier SHA-256, valide clés (pas de NUL/vide) et applique quota (somme tailles en excluant overwrite) avant écriture.
Host interface implementations
src-tauri/crates/core/src/plugin/host_impl.rs (send, emit, storage, linker, tests)
HostCtx::send applique offline-probe puis permissions, exécute requêtes via client bloquant, mappe réponses; emit route vers tracing; storage accède assets et StateStore; add_to_linker enregistre imports; tests pour glob, quota, overwrite, clé NUL et concurrence.
Runtime wiring: Store, Linker, HostCtx
src-tauri/crates/core/src/plugin/runtime.rs
new_store_for_plugin crée Store<HostCtx> enrichi (plugin_id, permissions, assets?, StateStore, client HTTP bloquant) et applique limites sandbox; build_linker instancie Linker<HostCtx> et injecte imports; HostCtx étendu; RuntimeError reçoit variantes Host, HttpClient, PluginIdMismatch; tests ajoutés.

Sequence Diagram(s)

sequenceDiagram
  participant Plugin as WASM Plugin
  participant HostCtx
  participant Permissions as HostPermissions
  participant HTTP as reqwest_blocking_Client
  participant StateStore

  Plugin->>HostCtx: send(request)
  HostCtx->>Permissions: http_allowed(url)?
  Permissions-->>HostCtx: bool
  alt denied
    HostCtx-->>Plugin: Err("permission denied")
  else allowed
    HostCtx->>HTTP: execute(request)
    HTTP-->>HostCtx: response
    HostCtx-->>Plugin: Ok(response)
  end

  Plugin->>HostCtx: write_state(key,value)
  HostCtx->>StateStore: validate key & check quota
  alt invalid or quota exceeded
    StateStore-->>HostCtx: Err(String)
    HostCtx-->>Plugin: Err(String)
  else
    StateStore->>StateStore: write SHA-256 file (tmp -> rename)
    StateStore-->>HostCtx: Ok(())
    HostCtx-->>Plugin: Ok(())
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • InstaZDLL/WaveFlow#221: PR étendant le squelette runtime et introduisant des éléments de HostCtx/PluginRuntime liés ici.
  • InstaZDLL/WaveFlow#220: PR qui introduit la base plugin-sdk, Manifest, AssetResolver et PluginPaths utilisée et étendue par ces changements.

Poem

🔌 L'hôte écoute, le plugin appelle sans fracas,
Globes gardiens et quotas veillent pas à pas,
SHA écrit sûr, atomique, sans éclat,
Wasmtime relie, client bloquant tient le pas,
Ensemble, plugin et hôte composent l'état-bas.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Le titre suit correctement Conventional Commits avec un scope kebab-case (plugin-sdk) et décrit clairement la contribution principale : l'ajout des liaisons hôte et des portes de permission pour la Phase 2b.
Description check ✅ Passed La description est complète et détaillée : elle couvre le résumé, les changements clés, le plan de test validé, et cite les références RFC/PR pertinentes. Tous les éléments clés du template sont fournis.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/plugin-sdk-phase-2b

Comment @coderabbitai help to get the list of available commands and usage tips.

@InstaZDLL InstaZDLL added type: feat New feature size: xl > 500 lines labels Jun 10, 2026
The shared blocking client was built with reqwest's default
redirect policy (follow up to 10 hops). The HTTP allowlist only
gates the initial `req.url` — once a request lands at an
allowlisted host, that server can 302 to any URL it wants and the
client would happily follow, granting transitive access to
arbitrary destinations the manifest never authorised.

Build the client on `PluginRuntime` (one place, configured at
startup) with `redirect(Policy::none())`. The plugin sees 3xx +
`Location` in the response and can re-issue through the allowlist
if it wants to follow — that way every hop is gated, not just the
first. Per-Store HostCtx clones the runtime's client; reqwest's
internal Arc pool means clones share one warm TLS / DNS cache.

`HostCtx::stub()` matches the production policy so tests don't pass
with permissive defaults the production runtime explicitly
disables. New `RuntimeError::HttpClient(reqwest::Error)` variant
for the (theoretical) builder failure path.

30/30 plugin tests pass.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/crates/core/src/plugin/host_impl.rs`:
- Around line 247-291: The HTTP host implementation (HostCtx::send) currently
checks permissions but does not heed offline mode; before performing
builder.send() call, call offline::is_offline() and short-circuit to return an
appropriate empty/cached wit_host::http::Response (e.g., zero-length body, empty
headers and a suitable status) instead of issuing the network request; keep the
existing permission check (permissions.http_allowed) and ensure errors still
return Err(String) where appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 607a19d8-8be2-4943-a9e7-791eae51e6e7

📥 Commits

Reviewing files that changed from the base of the PR and between 09af2ba and 882bc3d.

⛔ Files ignored due to path filters (1)
  • src-tauri/Cargo.lock is excluded by !**/*.lock, !src-tauri/Cargo.lock
📒 Files selected for processing (11)
  • src-tauri/crates/core/Cargo.toml
  • src-tauri/crates/core/src/plugin/bindings.rs
  • src-tauri/crates/core/src/plugin/host_impl.rs
  • src-tauri/crates/core/src/plugin/mod.rs
  • src-tauri/crates/core/src/plugin/runtime.rs
  • src-tauri/crates/plugin-sdk/wit/metadata/deps/host/host.wit
  • src-tauri/crates/plugin-sdk/wit/metadata/plugin.wit
  • src-tauri/crates/plugin-sdk/wit/source/deps/host/host.wit
  • src-tauri/crates/plugin-sdk/wit/source/plugin.wit
  • src-tauri/crates/plugin-sdk/wit/ui/deps/host/host.wit
  • src-tauri/crates/plugin-sdk/wit/ui/plugin.wit

Comment thread src-tauri/crates/core/src/plugin/host_impl.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/crates/core/src/plugin/runtime.rs`:
- Around line 112-125: The shared blocking HTTP client (http_client) is built
with redirect following disabled via Policy::none() but lacks an explicit
timeout; update the client construction to set a reasonable request timeout
(e.g., a few seconds) using reqwest::blocking::Client::builder().timeout(...) so
requests are bounded even if wasmtime's epoch deadline isn't hit, keeping
Policy::none() and the redirect behavior intact; locate the client build code
that sets redirect to Policy::none() and add the timeout configuration there
(affecting the http_client field).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4d92bbbe-ed93-45d4-bffe-e618503322a6

📥 Commits

Reviewing files that changed from the base of the PR and between 882bc3d and 79b4de8.

📒 Files selected for processing (1)
  • src-tauri/crates/core/src/plugin/runtime.rs

Comment thread src-tauri/crates/core/src/plugin/runtime.rs
@InstaZDLL InstaZDLL self-assigned this Jun 10, 2026
Two CR findings on Phase 2b's HTTP host import:

1) The shared blocking client had `Policy::none()` for redirects
   but no request / connect timeout. A misbehaving allowlisted host
   would hold the call open until wasmtime's epoch deadline (~30 s)
   trapped the entire guest call — clean error surfacing is better
   than a trap. Add `timeout(15 s)` + `connect_timeout(5 s)` so a
   stalled server returns a recoverable `reqwest::Error` the plugin
   sees as `Err(...)` while the epoch stays the last-resort catch.

2) CLAUDE.md spells "every outbound HTTP path checks
   `offline::is_offline()` first and short-circuits to an empty
   payload or cache". Plugin HTTP was missing this hook. The real
   `offline::is_offline` lives in the app crate so `waveflow-core`
   can't call it directly (circular dep, and core also feeds
   `waveflow-server`). Solution: introduce
   `OfflineProbe = Arc<dyn Fn() -> bool + Send + Sync>` as the
   public hook + `always_online()` default for tests + core
   consumers + `PluginRuntime::new_with_offline_probe(config,
   probe)` constructor for the host to inject its real probe. Phase
   3's host wiring will pass the desktop's `offline::is_offline`.

The HTTP send impl short-circuits to status=503 + empty headers +
empty body when the probe returns true — matches the empty-payload
convention from the rest of the workspace, and plugins keep their
normal HTTP error path (status + retry-later) instead of needing a
separate offline branch.

New test `http_send_short_circuits_when_offline` builds a HostCtx
with an always-true probe + a permissive allowlist and verifies
the response is the synthetic empty 503 without touching the
network. 31/31 plugin tests pass; workspace check clean.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src-tauri/crates/core/src/plugin/host_impl.rs (1)

192-223: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Le quota du StateStore se contourne sous écritures concurrentes.

write() fait un scan du répertoire puis écrit plus loin. Deux stores du même plugin peuvent donc calculer le même existing_total, passer tous les deux le check, puis finir au-dessus de quota_bytes. En plus, fs::write remplace le contenu en place, donc un read concurrent peut observer un fichier tronqué pendant un overwrite. Il faut sérialiser les writes par plugin et écrire via fichier temporaire + rename.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/crates/core/src/plugin/host_impl.rs` around lines 192 - 223, The
current write() races: two concurrent writers can both compute existing_total
and pass the quota check, and fs::write does in-place overwrites that can expose
truncated reads; fix by serializing writes per plugin and using atomic replace
via a temp file and rename: acquire an exclusive lock for the plugin directory
(e.g., open a dedicated lock file in self.dir and lock it) at the start of
write() before scanning for existing_total and keep the lock until after rename
completes; recompute the quota under that lock, write the value to a temp file
in the same directory (use a unique temp name derived from key_path()), fsync
the temp file if possible, then atomically rename the temp file to path
(key_path(key)) to replace the old file; keep the existing check_key(),
skip-equality logic for size calc, and return the same HostError::QuotaExceeded
when projected > quota_bytes, but perform these checks while holding the
exclusive lock to prevent races.
src-tauri/crates/core/src/plugin/runtime.rs (1)

296-329: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

new_store_for_plugin accepte un identifiant incohérent avec loaded.

La fonction dérive permissions et les déclarations d’assets depuis loaded.manifest, mais plugin_dir, state_dir et HostCtx.plugin_id depuis un plugin_id séparé. Si l’appelant se trompe d’id, le store d’un plugin A peut écrire dans le state dir de B et logger sous le mauvais identifiant. Dérive l’id depuis loaded.manifest.plugin.id ou rejette explicitement tout mismatch avant de construire HostCtx.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/crates/core/src/plugin/runtime.rs` around lines 296 - 329, The
new_store_for_plugin function uses a separate plugin_id for plugin_dir,
state_dir and HostCtx.plugin_id while assets and permissions come from
loaded.manifest, which can cause mismatches; update new_store_for_plugin to
obtain the canonical id from loaded.manifest.plugin.id (or at minimum validate
that the provided plugin_id equals loaded.manifest.plugin.id and return an error
on mismatch) before computing plugin_dir/state_dir and constructing
HostCtx.plugin_id, keeping references to ResourceTable, StateStore,
AssetResolver, HostPermissions, http_client and offline_probe as-is.
♻️ Duplicate comments (1)
src-tauri/crates/core/src/plugin/host_impl.rs (1)

252-267: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Le court-circuit hors-ligne doit passer avant la liste d’autorisation HTTP.

Ici, un appel fait en mode hors-ligne vers une URL non autorisée renverra encore permission denied au lieu de la réponse vide 503 attendue, parce que http_allowed est évalué avant offline_probe. Ça casse le contrat offline global, et le test ajouté ne verrouille pas cette régression puisqu’il n’exerce qu’une URL autorisée.

🔧 Correctif minimal
-        if !self.permissions.http_allowed(&req.url) {
-            return Ok(Err(format!("permission denied: {}", req.url)));
-        }
         if (self.offline_probe)() {
             return Ok(Ok(wit_host::http::Response {
                 status: 503,
                 headers: Vec::new(),
                 body: Vec::new(),
             }));
         }
+        if !self.permissions.http_allowed(&req.url) {
+            return Ok(Err(format!("permission denied: {}", req.url)));
+        }

As per coding guidelines, « Every outbound HTTP path (Deezer, Last.fm, similar, LRCLIB) must check offline::is_offline() first and short-circuit to an empty payload or cache. »

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/crates/core/src/plugin/host_impl.rs` around lines 252 - 267, The
offline short-circuit is checked after the permission check, causing offline
calls to unauthorized URLs to return "permission denied" instead of the expected
empty 503; move the offline check (use (self.offline_probe)()) to run before
calling self.permissions.http_allowed(&req.url) in the same function in
host_impl.rs so that the function returns the empty 503 Response early for all
outbound HTTP paths; ensure you preserve the existing returned Response shape
(status 503, empty headers/body) and adjust any related tests to cover an
offline call to a non-whitelisted URL.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src-tauri/crates/core/src/plugin/host_impl.rs`:
- Around line 192-223: The current write() races: two concurrent writers can
both compute existing_total and pass the quota check, and fs::write does
in-place overwrites that can expose truncated reads; fix by serializing writes
per plugin and using atomic replace via a temp file and rename: acquire an
exclusive lock for the plugin directory (e.g., open a dedicated lock file in
self.dir and lock it) at the start of write() before scanning for existing_total
and keep the lock until after rename completes; recompute the quota under that
lock, write the value to a temp file in the same directory (use a unique temp
name derived from key_path()), fsync the temp file if possible, then atomically
rename the temp file to path (key_path(key)) to replace the old file; keep the
existing check_key(), skip-equality logic for size calc, and return the same
HostError::QuotaExceeded when projected > quota_bytes, but perform these checks
while holding the exclusive lock to prevent races.

In `@src-tauri/crates/core/src/plugin/runtime.rs`:
- Around line 296-329: The new_store_for_plugin function uses a separate
plugin_id for plugin_dir, state_dir and HostCtx.plugin_id while assets and
permissions come from loaded.manifest, which can cause mismatches; update
new_store_for_plugin to obtain the canonical id from loaded.manifest.plugin.id
(or at minimum validate that the provided plugin_id equals
loaded.manifest.plugin.id and return an error on mismatch) before computing
plugin_dir/state_dir and constructing HostCtx.plugin_id, keeping references to
ResourceTable, StateStore, AssetResolver, HostPermissions, http_client and
offline_probe as-is.

---

Duplicate comments:
In `@src-tauri/crates/core/src/plugin/host_impl.rs`:
- Around line 252-267: The offline short-circuit is checked after the permission
check, causing offline calls to unauthorized URLs to return "permission denied"
instead of the expected empty 503; move the offline check (use
(self.offline_probe)()) to run before calling
self.permissions.http_allowed(&req.url) in the same function in host_impl.rs so
that the function returns the empty 503 Response early for all outbound HTTP
paths; ensure you preserve the existing returned Response shape (status 503,
empty headers/body) and adjust any related tests to cover an offline call to a
non-whitelisted URL.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 59b68c2a-ea0d-4a89-a378-7a5f1efe2995

📥 Commits

Reviewing files that changed from the base of the PR and between 79b4de8 and d655b2b.

📒 Files selected for processing (2)
  • src-tauri/crates/core/src/plugin/host_impl.rs
  • src-tauri/crates/core/src/plugin/runtime.rs

Three CR findings on Phase 2b:

1) Offline short-circuit was placed AFTER the HTTP allowlist check,
   so a guest probing a non-allowlisted URL while the host is
   offline saw "permission denied" instead of the empty 503 every
   other call gets. Worse, it let a plugin probe the allowlist
   state while offline — a small information leak. Swap order so
   the offline branch always wins.

2) `new_store_for_plugin` took a separate `plugin_id: &str` param
   alongside `loaded: &LoadedPlugin`, letting the install-dir id
   drift from `loaded.manifest.plugin.id` (assets would resolve
   against one tree while permissions + log scope referenced
   another). Drop the param; the manifest id is the single
   canonical source — already validated to `[a-z0-9-]+` by the
   parser and re-checked by `PluginPaths::sanitise_id`.

3) `StateStore::write` raced on two fronts: two concurrent writers
   could both observe the pre-write total + both pass the quota
   check, leaving the store over quota; and `fs::write` is not
   atomic, so a reader could see a truncated value mid-write.
   Hold an OS-level exclusive lock (`File::lock`, stable since
   Rust 1.89) on `.write-lock` for the duration of the quota check
   + the rename, write the value to a sibling `<hash>.tmp` first,
   then `fs::rename` onto the canonical path (atomic on both POSIX
   `rename` and Windows `MoveFileEx REPLACE_EXISTING` when source
   + dest live in the same dir on the same filesystem).

The lock file is excluded from the quota tally; a shared
`sum_state_dir_bytes` helper backs both `write`'s pre-flight check
and the test-only `used_bytes`.

4 new tests:
- `http_send_offline_supersedes_permission_denial` — offline wins
  over the allowlist branch.
- `state_store_lock_file_excluded_from_quota` — sentinel doesn't
  count against the cap.
- `state_store_serialises_concurrent_writes` — 2 threads writing
  the same key 10× each; final value is one pure side (no torn
  write), final usage is exactly one record (lock-serialised
  quota accounting).

34/34 plugin tests pass; workspace check clean.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/crates/core/src/plugin/host_impl.rs`:
- Around line 238-252: The temp write path (tmp_path created with
with_extension("tmp") in the write logic) can be left behind after a crash and
is currently counted by sum_state_dir_bytes(), so update sum_state_dir_bytes()
to ignore files with the ".tmp" extension (or match the same tmp naming rule
used when creating tmp_path) when computing quota; additionally, in the write
code paths that create tmp_path (the block using fs::write(&tmp_path, value) and
fs::rename(&tmp_path, &path) and the similar block around lines 274-293), ensure
tmp files are either cleaned up on startup or documented/consistent with the
ignore rule so orphaned "<hash>.tmp" files do not inflate the counted state
size.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5b2fc002-a350-492e-88db-44157e17d74c

📥 Commits

Reviewing files that changed from the base of the PR and between d655b2b and c74983b.

📒 Files selected for processing (2)
  • src-tauri/crates/core/src/plugin/host_impl.rs
  • src-tauri/crates/core/src/plugin/runtime.rs

Comment thread src-tauri/crates/core/src/plugin/host_impl.rs
Comment thread src-tauri/crates/core/src/plugin/runtime.rs
A host crash between `fs::write(tmp_path)` and
`fs::rename(tmp_path, path)` in `StateStore::write` leaves an
orphaned `<hash>.tmp` next to the canonical file. The next
successful write of that key overwrites the same tmp path, so the
on-disk surface is bounded — but `sum_state_dir_bytes` was
counting orphans toward the quota. Over the lifetime of a long-
lived install, that's a real footgun: every interrupted write the
store ever survived eats into the user's available space until the
plugin can no longer make new writes at all.

Skip `.tmp` siblings in the quota tally. The store layout is
self-healing — orphans get overwritten on the next write of the
same key, so we don't need a startup sweep; we only need to stop
counting them.

New test `state_store_ignores_orphan_tmp_files` synthesises an
orphan via direct `fs::write`, asserts `used_bytes()` reports 0,
and confirms a quota-saturating write still succeeds. 35/35 plugin
tests pass.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src-tauri/crates/core/src/plugin/host_impl.rs (1)

314-330: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Préservez le contrat WIT pour les URL refusées.

http.send doit renvoyer Err("permission denied: <url>") pour une URL hors allowlist, sans dépendre de l’état offline. Avec l’ordre actuel, la même requête renvoie un 503 synthétique dès que le host est hors-ligne, ce qui casse le contrat observable côté guest. Inversez l’ordre : vérifiez d’abord http_allowed, puis court-circuitez en 503 uniquement pour les URL déjà autorisées avant builder.send().

Correctif minimal
-        if (self.offline_probe)() {
-            return Ok(Ok(wit_host::http::Response {
-                status: 503,
-                headers: Vec::new(),
-                body: Vec::new(),
-            }));
-        }
         if !self.permissions.http_allowed(&req.url) {
             return Ok(Err(format!("permission denied: {}", req.url)));
         }
+        if (self.offline_probe)() {
+            return Ok(Ok(wit_host::http::Response {
+                status: 503,
+                headers: Vec::new(),
+                body: Vec::new(),
+            }));
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/crates/core/src/plugin/host_impl.rs` around lines 314 - 330, The
WIT contract requires http.send to return Err("permission denied: <url>") for
non-allowlisted URLs regardless of offline state; currently self.offline_probe
is checked before permission which turns denied URLs into 503s when offline.
Move the permission check (self.permissions.http_allowed(&req.url)) ahead of the
offline short-circuit, so http_allowed is evaluated first and returns Err(...)
for disallowed URLs, and only for allowed URLs perform the self.offline_probe()
check to return the synthetic 503 before calling builder.send()/the outbound
path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src-tauri/crates/core/src/plugin/host_impl.rs`:
- Around line 314-330: The WIT contract requires http.send to return
Err("permission denied: <url>") for non-allowlisted URLs regardless of offline
state; currently self.offline_probe is checked before permission which turns
denied URLs into 503s when offline. Move the permission check
(self.permissions.http_allowed(&req.url)) ahead of the offline short-circuit, so
http_allowed is evaluated first and returns Err(...) for disallowed URLs, and
only for allowed URLs perform the self.offline_probe() check to return the
synthetic 503 before calling builder.send()/the outbound path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 76d896d8-67da-4bd3-b79d-f420e376aa57

📥 Commits

Reviewing files that changed from the base of the PR and between c74983b and 1ae48ff.

📒 Files selected for processing (1)
  • src-tauri/crates/core/src/plugin/host_impl.rs

Two findings flipped earlier decisions on Phase 2b:

1) The previous CR pass moved the offline short-circuit AHEAD of
   the HTTP allowlist check. That broke the WIT contract spelled in
   `host.wit`: "the host validates `url` against the plugin's
   `permissions.http` allowlist BEFORE dispatching. A url that's
   not on the list returns `Err(\"permission denied: <url>\")`".
   The permission gate is a protocol invariant the guest is
   allowed to rely on; offline mode is a host-state short-circuit
   that runs after it. The information-leak argument that
   motivated the swap doesn't hold — the guest ships its own
   manifest, so it always knows its own allowlist.

   Restore: permission denial first, offline short-circuit second.
   Test `http_send_offline_supersedes_permission_denial` renamed +
   inverted to `http_send_permission_denial_wins_over_offline`
   (offline + non-allowlisted now correctly returns
   `Err("permission denied: ...")`). The
   `http_send_short_circuits_when_offline` test still covers the
   offline-with-permission path.

2) `new_store_for_plugin` now sources the plugin id from
   `loaded.manifest.plugin.id`, but `load_plugin(paths, plugin_id)`
   still reads the wasm + manifest from `plugins/<plugin_id>/`
   without verifying that the manifest's declared id matches.
   Without a load-time pin, a plugin installed under `plugins/foo/`
   declaring `id = "bar"` would later read assets from
   `plugins/bar/` and write state into `plugin-data/bar/` once the
   store keyed off the manifest id — the install dir effectively
   forgers a different plugin's resources.

   New `RuntimeError::PluginIdMismatch { expected, found }` variant
   + a check in `load_plugin` right after the manifest parse.
   New test `load_plugin_rejects_id_mismatch_between_dir_and_manifest`
   lays down `plugins/foo/manifest.toml` with `id = "bar"` and
   confirms the load fails before the wasm is even read.

36/36 plugin tests pass.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src-tauri/crates/core/src/plugin/host_impl.rs (1)

351-369: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bornez la taille du body HTTP côté hôte.

resp.bytes() bufferise toute la réponse en mémoire hôte avant le retour WIT. Un plugin autorisé peut donc forcer une allocation arbitrairement grosse, ce qui contourne max_memory_bytes du store et peut faire OOM le process. Lisez le flux via un take() borné et retournez Err(...) au-delà d’un plafond explicite.

Correctif minimal
+const MAX_HTTP_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
+
-        let body = match resp.bytes() {
-            Ok(b) => b.to_vec(),
-            Err(e) => return Ok(Err(e.to_string())),
-        };
+        let mut body = Vec::new();
+        if let Err(e) = resp
+            .take((MAX_HTTP_RESPONSE_BYTES as u64) + 1)
+            .read_to_end(&mut body)
+        {
+            return Ok(Err(e.to_string()));
+        }
+        if body.len() > MAX_HTTP_RESPONSE_BYTES {
+            return Ok(Err(format!(
+                "http response too large: {} bytes (cap {})",
+                body.len(),
+                MAX_HTTP_RESPONSE_BYTES
+            )));
+        }

Ajoutez aussi use std::io::Read; aux imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/crates/core/src/plugin/host_impl.rs` around lines 351 - 369, The
code currently calls resp.bytes() which buffers the entire response into memory;
change this to stream-read with a hard cap: import std::io::Read, wrap the
response body reader with resp.take(MAX_BODY_BYTES) (choose a sensible
MAX_BODY_BYTES constant), read into a Vec by repeatedly reading until EOF, and
if a subsequent read returns 0 bytes because the take limit was reached (or
detect that total_bytes_read == MAX_BODY_BYTES and further data exists) return
Ok(Err("response body too large".to_string())); keep the existing handling of
status and headers but replace usage of resp.bytes() with the bounded read from
the resp reader; ensure errors from reading are converted to Err(e.to_string())
as before.
src-tauri/crates/core/src/plugin/runtime.rs (1)

393-429: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fermez HostCtx aux mutations externes.

Avec des champs pub, n’importe quel appelant qui récupère un Store<HostCtx> peut réécrire permissions, plugin_id, assets ou offline_probe après l’instanciation. Ça casse précisément les invariants que ce runtime vient de pinner au chargement (PluginIdMismatch, snapshot des permissions). Passez ces champs en pub(crate) au minimum.

Correctif minimal
 pub struct HostCtx {
-    pub table: ResourceTable,
-    pub limits: StoreLimits,
-    pub plugin_id: String,
-    pub permissions: HostPermissions,
-    pub assets: Option<AssetResolver>,
-    pub state: StateStore,
-    pub http_client: reqwest::blocking::Client,
-    pub offline_probe: OfflineProbe,
+    pub(crate) table: ResourceTable,
+    pub(crate) limits: StoreLimits,
+    pub(crate) plugin_id: String,
+    pub(crate) permissions: HostPermissions,
+    pub(crate) assets: Option<AssetResolver>,
+    pub(crate) state: StateStore,
+    pub(crate) http_client: reqwest::blocking::Client,
+    pub(crate) offline_probe: OfflineProbe,
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/crates/core/src/plugin/runtime.rs` around lines 393 - 429, Les
champs exposés de HostCtx permettent des mutations externes et cassent les
invariants; changez au minimum la visibilité de plugin_id, permissions, assets
et offline_probe de pub à pub(crate) dans la définition de struct HostCtx pour
empêcher des réécritures hors du crate tout en gardant l'accès interne
nécessaire (conserver les autres champs tels quels). Assurez-vous de mettre à
jour toute référence externe si elle compile différemment après ce changement.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src-tauri/crates/core/src/plugin/host_impl.rs`:
- Around line 351-369: The code currently calls resp.bytes() which buffers the
entire response into memory; change this to stream-read with a hard cap: import
std::io::Read, wrap the response body reader with resp.take(MAX_BODY_BYTES)
(choose a sensible MAX_BODY_BYTES constant), read into a Vec by repeatedly
reading until EOF, and if a subsequent read returns 0 bytes because the take
limit was reached (or detect that total_bytes_read == MAX_BODY_BYTES and further
data exists) return Ok(Err("response body too large".to_string())); keep the
existing handling of status and headers but replace usage of resp.bytes() with
the bounded read from the resp reader; ensure errors from reading are converted
to Err(e.to_string()) as before.

In `@src-tauri/crates/core/src/plugin/runtime.rs`:
- Around line 393-429: Les champs exposés de HostCtx permettent des mutations
externes et cassent les invariants; changez au minimum la visibilité de
plugin_id, permissions, assets et offline_probe de pub à pub(crate) dans la
définition de struct HostCtx pour empêcher des réécritures hors du crate tout en
gardant l'accès interne nécessaire (conserver les autres champs tels quels).
Assurez-vous de mettre à jour toute référence externe si elle compile
différemment après ce changement.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: deb5915f-bbfb-41ef-913e-e6cbd465afaa

📥 Commits

Reviewing files that changed from the base of the PR and between 1ae48ff and 2bcbe62.

📒 Files selected for processing (2)
  • src-tauri/crates/core/src/plugin/host_impl.rs
  • src-tauri/crates/core/src/plugin/runtime.rs

Two CR findings on Phase 2b's wasm-facing surface:

1) `wit_host::http::Host::send` was calling `resp.bytes()` which
   pulls the whole response into a `Vec<u8>` in one go. A
   misbehaving (or malicious) allowlisted server could stream
   multi-GB into our buffer before wasmtime's epoch deadline ever
   trapped the call — we'd have allocated the worst-case payload
   first. Add `MAX_BODY_BYTES = 10 MiB` (3× margin over the
   biggest realistic JSON catalogue we know about — radio-browser
   station lists) and stream-read via `Read::take(MAX + 1)`
   so we never hold more than `MAX + 1` bytes; a saturating count
   surfaces as a clean `Err("response body too large")` the plugin
   can pivot on. Same pattern Phase 2a uses for `plugin.wasm`.

2) `HostCtx` exposed `plugin_id` / `permissions` / `assets` /
   `offline_probe` as `pub`. External crates (app, server)
   instantiate `HostCtx` via `PluginRuntime::new_store_for_plugin`
   but should never be able to rewrite those fields post-hoc —
   the permission snapshot pinned at instantiate time is a runtime
   invariant the WIT contract relies on, and a caller flipping
   `offline_probe` to `|| false` could bypass the host's offline
   switch. Narrow visibility to `pub(crate)`. `table`, `limits`,
   `state`, `http_client` stay `pub`: wasmtime + the host imports
   legitimately reach into them and there's no equivalent
   invariant to protect.

36/36 plugin tests pass; workspace check clean.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src-tauri/crates/core/src/plugin/host_impl.rs (1)

327-352: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Faites passer le court-circuit offline avant le contrôle d’allowlist.

Ici, un runtime offline renvoie encore permission denied pour une URL non autorisée, alors que la règle repo impose qu’un chemin HTTP sortant coupe d’abord sur le mode hors-ligne avec une payload vide/cache. Le test http_send_permission_denial_wins_over_offline verrouille aussi l’ordre inverse, donc il faudra le réaligner en même temps.

🔧 Correctif minimal
-        if !self.permissions.http_allowed(&req.url) {
-            return Ok(Err(format!("permission denied: {}", req.url)));
-        }
-        // Offline mode short-circuit runs AFTER the allowlist gate
-        // but BEFORE the network dispatch. Returns an empty 503 so
+        // Offline mode short-circuit runs BEFORE any other outbound
+        // HTTP handling. Returns an empty 503 so
         // plugins can keep their normal HTTP error path (status +
         // retry-later) instead of needing a separate offline
         // branch — matches the empty-payload convention CLAUDE.md
         // spells for every other outbound HTTP path (Deezer,
         // Last.fm, LRCLIB).
         if (self.offline_probe)() {
             return Ok(Ok(wit_host::http::Response {
                 status: 503,
                 headers: Vec::new(),
                 body: Vec::new(),
             }));
         }
+        if !self.permissions.http_allowed(&req.url) {
+            return Ok(Err(format!("permission denied: {}", req.url)));
+        }

As per coding guidelines, “Every outbound HTTP path (Deezer, Last.fm, similar, LRCLIB) must check offline::is_offline() first and short-circuit to an empty payload or cache. Process-wide offline mode is persisted in app_setting['network.offline_mode']”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/crates/core/src/plugin/host_impl.rs` around lines 327 - 352, Move
the offline short-circuit to run before the allowlist check: call the closure
self.offline_probe() (or use offline::is_offline() per guideline) first and if
it returns true immediately return the empty 503 wit_host::http::Response
(status: 503, empty headers/body) so outbound HTTP paths short-circuit on
offline mode before any permission checks; then perform
self.permissions.http_allowed(&req.url) and return the existing
permission-denied Err if not allowed. Also update the test
http_send_permission_denial_wins_over_offline to expect the allowlist denial
only after you've realigned the check order (or adjust it to assert offline wins
if keeping the new behavior).

Source: Coding guidelines

src-tauri/crates/core/src/plugin/runtime.rs (1)

423-437: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ne laissez pas state et http_client mutables hors crate.

Store<HostCtx> est retourné aux crates appelantes. Avec ces deux champs en pub, un appelant externe peut encore remplacer le backend d’état ou injecter un client reqwest avec une politique de redirection/timeout différente après l’instanciation, ce qui casse l’isolation et les garde-fous gelés au load-time. host_impl.rs n’a besoin ici que d’un accès crate-local.

🔒 Correctif minimal
-    pub state: StateStore,
+    pub(crate) state: StateStore,
@@
-    pub http_client: reqwest::blocking::Client,
+    pub(crate) http_client: reqwest::blocking::Client,

Le résumé du PR annonce explicitement un verrouillage des invariants de HostCtx; ces deux champs restent encore réécrivables depuis l’extérieur du crate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/crates/core/src/plugin/runtime.rs` around lines 423 - 437, The
fields state and http_client on HostCtx are currently public and can be mutated
by external crates through the returned Store<HostCtx>, breaking the intended
invariants; change their visibility from pub to pub(crate) so only crate-local
code (e.g. host_impl.rs) can access/replace the StateStore and
reqwest::blocking::Client, and update any internal uses that rely on the new
visibility accordingly (look for the HostCtx struct and references to state and
http_client in host_impl.rs and callers returning Store<HostCtx>).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src-tauri/crates/core/src/plugin/host_impl.rs`:
- Around line 327-352: Move the offline short-circuit to run before the
allowlist check: call the closure self.offline_probe() (or use
offline::is_offline() per guideline) first and if it returns true immediately
return the empty 503 wit_host::http::Response (status: 503, empty headers/body)
so outbound HTTP paths short-circuit on offline mode before any permission
checks; then perform self.permissions.http_allowed(&req.url) and return the
existing permission-denied Err if not allowed. Also update the test
http_send_permission_denial_wins_over_offline to expect the allowlist denial
only after you've realigned the check order (or adjust it to assert offline wins
if keeping the new behavior).

In `@src-tauri/crates/core/src/plugin/runtime.rs`:
- Around line 423-437: The fields state and http_client on HostCtx are currently
public and can be mutated by external crates through the returned
Store<HostCtx>, breaking the intended invariants; change their visibility from
pub to pub(crate) so only crate-local code (e.g. host_impl.rs) can
access/replace the StateStore and reqwest::blocking::Client, and update any
internal uses that rely on the new visibility accordingly (look for the HostCtx
struct and references to state and http_client in host_impl.rs and callers
returning Store<HostCtx>).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5ee0320d-b67f-4354-8684-008093b92673

📥 Commits

Reviewing files that changed from the base of the PR and between 2bcbe62 and 561f6bc.

📒 Files selected for processing (2)
  • src-tauri/crates/core/src/plugin/host_impl.rs
  • src-tauri/crates/core/src/plugin/runtime.rs

Two CR findings — both partially walking back earlier decisions:

1) HTTP ordering: offline short-circuit FIRST, then allowlist. The
   previous round flipped to allowlist-first citing the WIT
   contract; that was an over-read. The WIT spec pins permission
   ahead of the network DISPATCH ("validates url against the
   allowlist BEFORE dispatching") — it doesn't say anything about
   the order of host-state short-circuits like offline mode. Both
   gates still fire before any `builder.send()`, so the WIT
   invariant is honoured either way.

   CLAUDE.md is the deciding rule: "every outbound HTTP path
   (Deezer, Last.fm, similar, LRCLIB) checks `offline::is_offline()`
   first and short-circuits to an empty payload or cache. Treat new
   HTTP code paths the same way." Plugin HTTP is a new HTTP code
   path, so it matches the rest of the workspace.

   Restored: offline → 503 → permission → "permission denied" →
   dispatch. Test renamed back to
   `http_send_offline_supersedes_permission_denial`. An explicit
   ORDERING INVARIANT comment in `Host::send` flags the contract +
   the deciding rule + the lookback reasoning so future review
   rounds don't yo-yo on this again.

2) `HostCtx::state` and `http_client` narrowed from `pub` to
   `pub(crate)`. The previous round locked
   plugin_id/permissions/assets/offline_probe but reasoned state +
   http_client were "legitimately reached into" — true for the host
   imports, but those live in the same crate. An external caller
   holding `Store<HostCtx>` could still swap `state` to point at
   another plugin's scratch (or anywhere on disk), or replace
   `http_client` with one that follows redirects + drops timeouts,
   undoing the runtime's HTTP policy. Lock both. Only `table` +
   `limits` stay `pub` — wasmtime itself reaches into them and
   there's no equivalent invariant to protect.

36/36 plugin tests pass; workspace check clean.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: backend Rust/Tauri backend (src-tauri/) scope: deps Dependencies size: xl > 500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant