From a2d7d659e6cee2573d4de04cb8e64947d1bd4e9f Mon Sep 17 00:00:00 2001
From: CharlesHsu
Date: Fri, 30 Oct 2026 19:32:59 +0800
Subject: [PATCH 1/4] feat: add scoped publishing snapshots and authorized
plugin networking
---
packages/plugin-api/build.mjs | 2 +
packages/plugin-api/index.d.ts | 9 +-
packages/plugin-api/package.json | 1 +
packages/plugin-api/publishing.d.ts | 87 ++++
src-tauri/Cargo.lock | 17 +
src-tauri/Cargo.toml | 2 +-
src-tauri/src/plugins.rs | 103 +++-
src-tauri/src/plugins/http.rs | 305 +++++++++--
src-tauri/src/plugins/network.rs | 502 +++++++++++++++++++
src-tauri/src/plugins/tests.rs | 2 +-
src-tauri/src/plugins/windows_permissions.rs | 1 +
src/App.vue | 3 +-
src/app/createApplication.ts | 5 +
src/app/usePluginNetworkConsent.ts | 28 ++
src/app/views/NetworkConsentDialog.vue | 41 ++
src/application/library-publishing.test.ts | 250 +++++++++
src/application/library-publishing.ts | 228 +++++++++
src/application/library.ts | 2 +
src/components/PluginPanel.vue | 7 +-
src/core/source-properties.ts | 7 +-
src/i18n/en.ts | 1 +
src/plugins/manager.ts | 37 ++
src/plugins/package.ts | 1 +
src/plugins/publishing-sdk.test.ts | 131 +++++
src/plugins/publishing.ts | 15 +
src/plugins/sdk.ts | 98 ++++
26 files changed, 1835 insertions(+), 50 deletions(-)
create mode 100644 packages/plugin-api/publishing.d.ts
create mode 100644 src-tauri/src/plugins/network.rs
create mode 100644 src/app/usePluginNetworkConsent.ts
create mode 100644 src/app/views/NetworkConsentDialog.vue
create mode 100644 src/application/library-publishing.test.ts
create mode 100644 src/application/library-publishing.ts
create mode 100644 src/plugins/publishing-sdk.test.ts
create mode 100644 src/plugins/publishing.ts
diff --git a/packages/plugin-api/build.mjs b/packages/plugin-api/build.mjs
index ca6f180f..b7a6c77f 100644
--- a/packages/plugin-api/build.mjs
+++ b/packages/plugin-api/build.mjs
@@ -25,6 +25,8 @@ const runtimeExports = [
'materials',
'study',
'automation',
+ 'publishing',
+ 'network',
'tasks',
'resources',
'practiceContentRevision',
diff --git a/packages/plugin-api/index.d.ts b/packages/plugin-api/index.d.ts
index 30e614ca..5e56b400 100644
--- a/packages/plugin-api/index.d.ts
+++ b/packages/plugin-api/index.d.ts
@@ -13,6 +13,7 @@ export * from './collection'
export * from './study'
export * from './automation'
export * from './providers'
+export * from './publishing'
export const automation: import('./automation').AutomationApi
import type { MaterialLocator } from './materials'
@@ -230,7 +231,13 @@ export interface PluginManifest {
dataVersion: number
kind: 'frontend' | 'local'
description?: string
- permissions?: { origins?: string[]; secrets?: boolean; files?: boolean; models?: boolean }
+ permissions?: {
+ origins?: string[]
+ secrets?: boolean
+ files?: boolean
+ models?: boolean
+ network?: boolean
+ }
}
export declare const locale: DeepReadonly[>
export declare function t(
diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json
index bae2230f..f917bb4d 100644
--- a/packages/plugin-api/package.json
+++ b/packages/plugin-api/package.json
@@ -21,6 +21,7 @@
"study.d.ts",
"automation.d.ts",
"providers.d.ts",
+ "publishing.d.ts",
"build.d.mts",
"build.mjs"
],
diff --git a/packages/plugin-api/publishing.d.ts b/packages/plugin-api/publishing.d.ts
new file mode 100644
index 00000000..f8dc5b23
--- /dev/null
+++ b/packages/plugin-api/publishing.d.ts
@@ -0,0 +1,87 @@
+/** Explicit public projection. Internal identity is for local bindings, never template variables. */
+export type PublishingField = string | number | boolean | string[]
+export interface PublishingAsset {
+ id: string
+ reference: string
+ type: string
+ size: number
+}
+export interface PublishingSource {
+ id: string
+ versionId: string
+ title: string
+ body: string
+ format: 'plain' | 'markdown'
+ createdAt: string
+ updatedAt: string
+ fields: Record
+ assets: PublishingAsset[]
+ /** Resolved references may target unselected materials; never includes their content or fields. */
+ links: { reference: string; sourceId: string; fragment?: string }[]
+}
+export interface PublishingSnapshot {
+ id: string
+ workspaceScope: string
+ revision: number
+ createdAt: string
+ sources: PublishingSource[]
+}
+export interface PublishingApi {
+ /** Titles only, for the explicit material picker. No bodies, metadata values or private paths. */
+ list(): {
+ workspaceScope: string
+ revision: number
+ sources: { id: string; title: string; versionId: string }[]
+ }
+ /** Exact selected IDs (including an explicit empty selection); never silently includes descendants. Only named scalar fields are public.
+ * Draft/private materials are rejected; frontmatter is stripped. Max 1000 materials / 32 MiB text.
+ */
+ prepare(
+ input: { sourceIds: string[]; expectedRevision: number; publicFields?: string[] },
+ signal?: AbortSignal,
+ ): Promise
+ /** Revalidate before export/upload; edits, expiry, replacement, disposal and library changes fail. */
+ validate(snapshotId: string): void
+ /** Only attachment references found in this snapshot. Never arbitrary library media or originals. */
+ asset(snapshotId: string, assetId: string, signal?: AbortSignal): Promise
+ release(snapshotId: string): void
+}
+export declare const publishing: PublishingApi
+
+export interface PluginHttpSecret {
+ slot: string
+ expectedRevision?: number
+ headers?: Record
+ body?: { template: string; encoding: 'xml' }
+}
+export type PluginHttpBody =
+ | string
+ | { base64: string }
+ | {
+ multipart: (
+ | { name: string; text: string }
+ | { name: string; base64: string; filename: string; type: string }
+ )[]
+ }
+export interface PluginHttpRequest {
+ url: string
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'
+ headers?: Record
+ body?: PluginHttpBody
+ secret?: PluginHttpSecret
+}
+export interface PluginHttpResponse {
+ status: number
+ headers: Record
+ body: string
+ credentialRevision: number | null
+ currentCredentialRevision: number | null
+}
+export declare const network: {
+ /** Host-owned confirmation, HTTPS origin only; requires manifest network:true.
+ * Grant lasts for this plugin activation/current library, not the saved configuration.
+ */
+ authorize(origin: string): Promise
+ /** Native transport. Cancellation does not imply a remote write was rolled back. */
+ request(input: PluginHttpRequest, signal?: AbortSignal): Promise
+}
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 67b4b0bb..66594689 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -2419,6 +2419,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3457,6 +3467,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
+ "mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -5051,6 +5062,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+[[package]]
+name = "unicase"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
+
[[package]]
name = "unicode-general-category"
version = "1.1.0"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index d0fa84b0..1aad54dd 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -16,7 +16,7 @@ tokio = { version = "1", features = ["sync", "net", "time", "process", "io-util"
tokio-util = { version = "0.7", features = ["rt"] }
axum = "0.8.9"
rmcp = { version = "=3.5.1", default-features = false, features = ["server", "transport-streamable-http-server", "client", "transport-streamable-http-client-reqwest", "transport-io"] }
-reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "json"] }
+reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "json", "multipart"] }
base64 = "0.22"
open = "5"
tauri-plugin-dialog = "2"
diff --git a/src-tauri/src/plugins.rs b/src-tauri/src/plugins.rs
index 7fdc1883..8f6ef41d 100644
--- a/src-tauri/src/plugins.rs
+++ b/src-tauri/src/plugins.rs
@@ -1,6 +1,7 @@
//! Runtime packages and generic native capabilities; no plugin business code is linked here.
mod http;
mod models;
+mod network;
mod secrets;
#[cfg(test)]
mod tests;
@@ -201,6 +202,8 @@ pub struct Permissions {
files: bool,
#[serde(default)]
models: bool,
+ #[serde(default)]
+ network: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -489,6 +492,7 @@ pub struct Plugins {
secrets: Arc>,
client: reqwest::Client,
models: Arc,
+ network: Arc,
}
impl Plugins {
pub fn new(directory: &Path) -> Result {
@@ -497,6 +501,7 @@ impl Plugins {
)?));
Ok(Self {
models: Arc::new(models::Models::new(directory, secrets.clone())?),
+ network: Arc::new(network::Network::default()),
secrets,
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
@@ -528,7 +533,14 @@ impl Plugins {
}
Ok(())
}
- fn operation(&self, root: Dir, id: &str, operation: &str, payload: Value) -> Result {
+ fn operation(
+ &self,
+ root: Dir,
+ id: &str,
+ operation: &str,
+ payload: Value,
+ binding: Option<&network::Binding>,
+ ) -> Result {
match operation {
"list" => {
let Some(packages) = private(&root, "plugins", false)? else {
@@ -597,10 +609,24 @@ impl Plugins {
if operation == "files" && package.manifest.permissions.files {
plugin_file(&root, id, payload)
} else if operation == "secrets" && package.manifest.permissions.secrets {
- self.secrets
+ let permissions = match binding {
+ Some(binding) => self
+ .network
+ .permissions(binding, &package.manifest.permissions)?,
+ None => package.manifest.permissions.clone(),
+ };
+ let slot = payload["slot"].as_str().map(str::to_owned);
+ let changed =
+ matches!(payload["action"].as_str(), Some("set" | "clear"));
+ let result = self
+ .secrets
.lock()
.map_err(|_| "plugin-storage-error")?
- .operation(id, &package.manifest.permissions, payload)
+ .operation(id, &permissions, payload)?;
+ if changed && let Some(slot) = slot {
+ self.network.cancel_slot(id, &slot);
+ }
+ Ok(result)
} else {
Err("permission-denied".into())
}
@@ -619,7 +645,53 @@ impl Plugins {
payload: Value,
) -> Result {
let host = self.clone();
- if operation.starts_with("model.") {
+ // Advance from the actual identity, never from an untrusted delayed call token.
+ {
+ let identity = workspaces.active_identity();
+ let current = identity.lock().map_err(|_| "workspace-changed")?;
+ if current.1 != token {
+ return Err("workspace-changed".into());
+ }
+ self.network.scope(¤t.1)?;
+ }
+ if matches!(
+ operation.as_str(),
+ "network.authorize" | "http.cancel" | "http.dispose"
+ ) {
+ let binding = network::Binding::from_payload(&token, &id, &payload)?;
+ crate::workspaces::with_plugin_workspace(workspaces, token, move |root| {
+ if !id_valid(&id) {
+ return Err(invalid());
+ }
+ // Cleanup must remain possible after the package is removed or replaced.
+ match operation.as_str() {
+ "http.dispose" => {
+ host.network.dispose(&binding);
+ Ok(Value::Null)
+ }
+ "http.cancel" => {
+ host.network.cancel(
+ &binding,
+ payload["requestId"].as_str().ok_or("invalid-data")?,
+ )?;
+ Ok(Value::Null)
+ }
+ _ => {
+ let package = package(&root, &id)?;
+ host.trusted(&package)?;
+ if !package.manifest.permissions.network {
+ return Err("permission-denied".into());
+ }
+ host.network.authorize(
+ &binding,
+ payload["origin"].as_str().ok_or("invalid-data")?,
+ )?;
+ Ok(Value::Null)
+ }
+ }
+ })
+ .await
+ } else if operation.starts_with("model.") {
let plugin_id = id.clone();
let disposing = operation == "model.dispose";
crate::workspaces::with_plugin_workspace(workspaces, token.clone(), move |root| {
@@ -648,6 +720,8 @@ impl Plugins {
crate::workspaces::with_plugin_workspace(workspaces, token, |_| Ok(())).await?;
response
} else if operation == "http" {
+ let binding = network::Binding::from_payload(&token, &id, &payload)?;
+ let request = self.network.register(&binding, &payload)?;
let prepared =
crate::workspaces::with_plugin_workspace(workspaces, token.clone(), move |root| {
if !id_valid(&id) {
@@ -655,15 +729,30 @@ impl Plugins {
}
let package = package(&root, &id)?;
host.trusted(&package)?;
- http::prepare(&host, &id, &package.manifest.permissions, payload)
+ let permissions = host
+ .network
+ .permissions(&binding, &package.manifest.permissions)?;
+ http::prepare(&host, &id, &permissions, payload)
})
.await?;
- let response = http::send(self, prepared).await;
+ let response = self
+ .network
+ .run(
+ &request,
+ workspaces.active_identity(),
+ http::send(self, prepared),
+ )
+ .await;
crate::workspaces::with_plugin_workspace(workspaces, token, |_| Ok(())).await?;
response
} else {
+ let binding = if payload.get("activationId").is_some() {
+ Some(network::Binding::from_payload(&token, &id, &payload)?)
+ } else {
+ None
+ };
crate::workspaces::with_plugin_workspace(workspaces, token, move |root| {
- host.operation(root, &id, &operation, payload)
+ host.operation(root, &id, &operation, payload, binding.as_ref())
})
.await
}
diff --git a/src-tauri/src/plugins/http.rs b/src-tauri/src/plugins/http.rs
index 7f55a513..4bc8ed94 100644
--- a/src-tauri/src/plugins/http.rs
+++ b/src-tauri/src/plugins/http.rs
@@ -1,4 +1,5 @@
use super::{Permissions, Plugins, Result, secrets::Snapshot};
+use base64::{Engine as _, engine::general_purpose::STANDARD};
use reqwest::{
Method, Url,
header::{HeaderMap, HeaderName, HeaderValue},
@@ -19,20 +20,45 @@ pub(super) fn origin(raw: &str) -> Result {
}
Ok(url.origin().ascii_serialization())
}
-fn render(template: &str, secret: &Snapshot) -> Result {
+const BODY_LIMIT: usize = 32 * 1024 * 1024;
+const PART_LIMIT: usize = 25 * 1024 * 1024;
+const RESPONSE_LIMIT: usize = 8 * 1024 * 1024;
+fn render(template: &str, secret: &Snapshot, xml: bool) -> Result {
let mut output = String::new();
+ let limit = if xml { BODY_LIMIT } else { 32768 };
+ let append = |output: &mut String, value: &str| -> Result<()> {
+ if output
+ .len()
+ .checked_add(value.len())
+ .is_none_or(|length| length > limit)
+ {
+ return Err("plugin-limit".into());
+ }
+ output.push_str(value);
+ Ok(())
+ };
let mut rest = template;
while let Some(start) = rest.find("{{") {
- output.push_str(&rest[..start]);
+ append(&mut output, &rest[..start])?;
let end = rest[start + 2..].find("}}").ok_or("invalid-data")? + start + 2;
let field = &rest[start + 2..end];
- output.push_str(secret.values.get(field).ok_or("invalid-data")?);
+ let value = secret.values.get(field).ok_or("invalid-data")?;
+ if xml {
+ append(
+ &mut output,
+ &value
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'"),
+ )?;
+ } else {
+ append(&mut output, value)?;
+ }
rest = &rest[end + 2..];
}
- output.push_str(rest);
- if output.len() > 32768 {
- return Err("plugin-limit".into());
- }
+ append(&mut output, rest)?;
Ok(output)
}
pub(super) struct Prepared {
@@ -94,6 +120,7 @@ pub(super) fn prepare(
}
let mut slot = None;
let mut revision = None;
+ let mut secret_body = None;
if let Some(secret) = payload.get("secret") {
if !permissions.secrets {
return Err("permission-denied".into());
@@ -114,27 +141,41 @@ pub(super) fn prepare(
if snapshot.origin != url.origin().ascii_serialization() {
return Err("permission-denied".into());
}
- let templates = secret["headers"]
- .as_object()
- .filter(|v| v.len() <= 16)
- .ok_or("invalid-data")?;
- for (name, template) in templates {
- let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| "invalid-data")?;
- if [
- "host",
- "connection",
- "content-length",
- "proxy-authorization",
- ]
- .contains(&name.as_str())
+ if let Some(templates) = secret.get("headers") {
+ for (name, template) in templates
+ .as_object()
+ .filter(|v| v.len() <= 16)
+ .ok_or("invalid-data")?
{
- return Err("permission-denied".into());
+ let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| "invalid-data")?;
+ if [
+ "host",
+ "connection",
+ "content-length",
+ "proxy-authorization",
+ ]
+ .contains(&name.as_str())
+ {
+ return Err("permission-denied".into());
+ }
+ let value = render(template.as_str().ok_or("invalid-data")?, &snapshot, false)?;
+ headers.insert(
+ name,
+ HeaderValue::from_str(&value).map_err(|_| "invalid-data")?,
+ );
}
- let value = render(template.as_str().ok_or("invalid-data")?, &snapshot)?;
- headers.insert(
- name,
- HeaderValue::from_str(&value).map_err(|_| "invalid-data")?,
- );
+ }
+ if let Some(body) = secret.get("body") {
+ if body["encoding"].as_str() != Some("xml")
+ || payload.get("body").is_some_and(|v| !v.is_null())
+ {
+ return Err("invalid-data".into());
+ }
+ let template = body["template"]
+ .as_str()
+ .filter(|v| v.len() <= BODY_LIMIT)
+ .ok_or("plugin-limit")?;
+ secret_body = Some(render(template, &snapshot, true)?);
}
slot = Some(key.to_owned());
revision = Some(snapshot.revision);
@@ -145,14 +186,71 @@ pub(super) fn prepare(
Method::from_bytes(method.as_bytes()).map_err(|_| "invalid-data")?,
url,
)
- .headers(headers);
- if let Some(body) = payload.get("body") {
- request = request.body(
- body.as_str()
- .filter(|v| v.len() <= 1024 * 1024)
- .ok_or("invalid-data")?
- .to_owned(),
- );
+ .headers(headers)
+ .timeout(std::time::Duration::from_secs(60));
+ if let Some(body) = secret_body {
+ request = request.body(body);
+ } else if let Some(body) = payload.get("body").filter(|v| !v.is_null()) {
+ if let Some(text) = body.as_str() {
+ if text.len() > BODY_LIMIT {
+ return Err("plugin-limit".into());
+ }
+ request = request.body(text.to_owned());
+ } else if let Some(value) = body.get("base64") {
+ request = request.body(decode(value, BODY_LIMIT)?);
+ } else if let Some(parts) = body.get("multipart") {
+ let parts = parts
+ .as_array()
+ .filter(|v| !v.is_empty() && v.len() <= 256)
+ .ok_or("invalid-data")?;
+ let mut form = reqwest::multipart::Form::new();
+ let mut total = 0;
+ for part in parts {
+ let name = part["name"]
+ .as_str()
+ .filter(|v| !v.is_empty() && v.len() <= 200 && !v.chars().any(char::is_control))
+ .ok_or("invalid-data")?;
+ let value = if let Some(text) = part.get("text") {
+ if part.get("base64").is_some() {
+ return Err("invalid-data".into());
+ }
+ let text = text
+ .as_str()
+ .filter(|v| v.len() <= PART_LIMIT)
+ .ok_or("plugin-limit")?;
+ total += text.len();
+ reqwest::multipart::Part::text(text.to_owned())
+ } else {
+ let bytes = decode(&part["base64"], PART_LIMIT)?;
+ total += bytes.len();
+ let name = part["filename"]
+ .as_str()
+ .filter(|v| {
+ !v.is_empty()
+ && v.len() <= 240
+ && !v.chars().any(|c| c.is_control() || matches!(c, '/' | '\\'))
+ })
+ .ok_or("invalid-data")?;
+ let mime = part["type"]
+ .as_str()
+ .filter(|v| v.len() <= 120)
+ .ok_or("invalid-data")?;
+ reqwest::multipart::Part::bytes(bytes)
+ .file_name(name.to_owned())
+ .mime_str(mime)
+ .map_err(|_| "invalid-data")?
+ };
+ // Reserve bounded space for generated MIME framing.
+ total += 1024;
+ if total > BODY_LIMIT {
+ return Err("plugin-limit".into());
+ }
+ form = form.part(name.to_owned(), value);
+ }
+ request = request.multipart(form);
+ } else {
+ return Err("invalid-data".into());
+ }
}
Ok(Prepared {
request,
@@ -161,6 +259,17 @@ pub(super) fn prepare(
revision,
})
}
+fn decode(value: &Value, limit: usize) -> Result> {
+ let encoded = value
+ .as_str()
+ .filter(|v| v.len() <= limit.div_ceil(3) * 4)
+ .ok_or("plugin-limit")?;
+ let bytes = STANDARD.decode(encoded).map_err(|_| "invalid-data")?;
+ if bytes.len() > limit {
+ return Err("plugin-limit".into());
+ }
+ Ok(bytes)
+}
pub(super) async fn send(host: &Plugins, prepared: Prepared) -> Result {
let mut response = prepared
.request
@@ -178,7 +287,7 @@ pub(super) async fn send(host: &Plugins, prepared: Prepared) -> Result {
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| "plugin-network-error")? {
- if bytes.len() + chunk.len() > 1024 * 1024 {
+ if bytes.len() + chunk.len() > RESPONSE_LIMIT {
return Err("plugin-response-limit".into());
}
bytes.extend_from_slice(&chunk);
@@ -203,6 +312,7 @@ pub(super) async fn send(host: &Plugins, prepared: Prepared) -> Result {
#[cfg(test)]
mod tests {
use super::*;
+ use std::sync::Arc;
fn fixture() -> (tempfile::TempDir, Plugins, Permissions) {
let directory = tempfile::tempdir().unwrap();
let host = Plugins::new(directory.path()).unwrap();
@@ -211,6 +321,7 @@ mod tests {
secrets: true,
files: false,
models: false,
+ network: false,
};
host.secrets.lock().unwrap().operation("sample",&permissions,json!({"action":"set","slot":"account","origin":"https://example.org","values":{"session":"private","csrf":"token"}})).unwrap();
(directory, host, permissions)
@@ -219,6 +330,128 @@ mod tests {
json!({"url":"https://example.org/submit","method":"POST","headers":{"content-type":"application/json"},"body":"{}","secret":{"slot":"account","expectedRevision":1,"headers":{"cookie":"SESSION={{session}}; csrf={{csrf}}","x-csrf-token":"{{csrf}}"}}})
}
#[test]
+ fn credential_rendering_stops_at_each_output_budget_before_later_placeholders() {
+ let secret = Snapshot {
+ revision: 1,
+ origin: "https://example.org".into(),
+ values: std::collections::BTreeMap::from([("password".into(), "x".repeat(8192))]),
+ };
+ for (xml, limit) in [(false, 32768), (true, BODY_LIMIT)] {
+ let exact = "{{password}}".repeat(limit / 8192);
+ assert_eq!(render(&exact, &secret, xml).unwrap().len(), limit);
+ // If interpolation continues past the budget this would instead be invalid-data.
+ for overflow in [
+ format!("{exact}{{{{password}}}}{{{{missing}}}}"),
+ format!("{exact}x{{{{missing}}}}"),
+ format!("{exact}x"),
+ ] {
+ assert_eq!(render(&overflow, &secret, xml).unwrap_err(), "plugin-limit");
+ }
+ }
+ }
+ #[test]
+ fn xml_credential_budget_counts_escaped_bytes() {
+ let secret = Snapshot {
+ revision: 1,
+ origin: "https://example.org".into(),
+ values: std::collections::BTreeMap::from([("password".into(), "&".repeat(8192))]),
+ };
+ let template = format!(
+ "{}{{{{missing}}}}",
+ "{{password}}".repeat(BODY_LIMIT / (8192 * "&".len()) + 1)
+ );
+ assert_eq!(
+ render(&template, &secret, true).unwrap_err(),
+ "plugin-limit"
+ );
+ }
+ #[test]
+ fn xml_credentials_are_escaped_without_exposing_or_mixing_secret_snapshots() {
+ let (_directory, host, permissions) = fixture();
+ host.secrets.lock().unwrap().operation("sample", &permissions, json!({"action":"set","slot":"account","origin":"https://example.org","values":{"password":"<&>\"'"}})).unwrap();
+ let value = json!({"url":"https://example.org/xmlrpc.php","method":"POST","secret":{"slot":"account","expectedRevision":2,"body":{"template":"{{password}}","encoding":"xml"}}});
+ let request = prepare(&host, "sample", &permissions, value.clone())
+ .unwrap()
+ .request
+ .build()
+ .unwrap();
+ assert_eq!(
+ request.body().unwrap().as_bytes().unwrap(),
+ b"<&>"'"
+ );
+ let mut conflicting = value.clone();
+ conflicting["body"] = json!("body");
+ assert!(prepare(&host, "sample", &permissions, conflicting).is_err());
+ let mut other_encoding = value;
+ other_encoding["secret"]["body"]["encoding"] = json!("raw");
+ assert!(prepare(&host, "sample", &permissions, other_encoding).is_err());
+ let binary = prepare(
+ &host,
+ "sample",
+ &permissions,
+ json!({"url":"https://example.org/upload","method":"POST","body":{"base64":"AP+A"}}),
+ )
+ .unwrap()
+ .request
+ .build()
+ .unwrap();
+ assert_eq!(binary.body().unwrap().as_bytes().unwrap(), &[0, 255, 128]);
+ }
+ #[tokio::test]
+ async fn multipart_upload_uses_binary_bytes_and_bounds_fields_and_paths() {
+ let (_directory, host, permissions) = fixture();
+ let value = json!({"url":"https://example.org/upload","method":"POST","body":{"multipart":[{"name":"manifest","text":"{\"中文\":1}"},{"name":"file","filename":"image.png","type":"image/png","base64":"AP+A"}]}});
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let (send, receive) = tokio::sync::oneshot::channel();
+ let send = Arc::new(tokio::sync::Mutex::new(Some(send)));
+ let server = tokio::spawn(async move {
+ let router = axum::Router::new().route(
+ "/",
+ axum::routing::post(move |request: axum::extract::Request| {
+ let send = send.clone();
+ async move {
+ let body = axum::body::to_bytes(request.into_body(), 1024 * 1024)
+ .await
+ .unwrap();
+ send.lock()
+ .await
+ .take()
+ .unwrap()
+ .send(body.to_vec())
+ .unwrap();
+ "ok"
+ }
+ }),
+ );
+ axum::serve(listener, router).await.unwrap();
+ });
+ let mut request = prepare(&host, "sample", &permissions, value.clone())
+ .unwrap()
+ .request
+ .build()
+ .unwrap();
+ *request.url_mut() = Url::parse(&format!("http://{address}/")).unwrap();
+ assert!(
+ host.client
+ .execute(request)
+ .await
+ .unwrap()
+ .status()
+ .is_success()
+ );
+ let bytes = receive.await.unwrap();
+ assert!(bytes.windows(3).any(|bytes| bytes == [0, 255, 128]));
+ assert!(String::from_utf8_lossy(&bytes).contains("{\"中文\":1}"));
+ server.abort();
+ let mut invalid = value.clone();
+ invalid["body"]["multipart"][1]["filename"] = json!("../image.png");
+ assert!(prepare(&host, "sample", &permissions, invalid).is_err());
+ let mut mixed = value;
+ mixed["body"]["multipart"][0]["base64"] = json!("eA==");
+ assert!(prepare(&host, "sample", &permissions, mixed).is_err());
+ }
+ #[test]
fn binds_secret_headers_to_declared_origin_and_expected_credential_revision() {
let (_directory, host, permissions) = fixture();
let request = prepare(&host, "sample", &permissions, request())
@@ -309,7 +542,7 @@ mod tests {
async fn response_body_limits_and_invalid_utf8_are_errors_without_automatic_retry() {
let (_directory, host, _permissions) = fixture();
for (body, error) in [
- (vec![b'x'; 1024 * 1024 + 1], "plugin-response-limit"),
+ (vec![b'x'; RESPONSE_LIMIT + 1], "plugin-response-limit"),
(vec![255], "invalid-response"),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
diff --git a/src-tauri/src/plugins/network.rs b/src-tauri/src/plugins/network.rs
new file mode 100644
index 00000000..45c7cd77
--- /dev/null
+++ b/src-tauri/src/plugins/network.rs
@@ -0,0 +1,502 @@
+//! Session-scoped network grants and cancellation. No connector business rules live here.
+use super::{Permissions, Result};
+use serde_json::Value;
+use std::{
+ collections::{HashMap, HashSet},
+ sync::{Arc, Mutex},
+ time::Duration,
+};
+use tokio_util::sync::CancellationToken;
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub(super) struct Binding {
+ token: String,
+ plugin: String,
+ activation: String,
+}
+fn identifier(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= 128
+ && value
+ .bytes()
+ .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
+}
+impl Binding {
+ pub fn from_payload(token: &str, plugin: &str, payload: &Value) -> Result {
+ let activation = payload["activationId"]
+ .as_str()
+ .filter(|v| identifier(v))
+ .ok_or("invalid-data")?;
+ Ok(Self {
+ token: token.into(),
+ plugin: plugin.into(),
+ activation: activation.into(),
+ })
+ }
+}
+struct Call {
+ binding: Binding,
+ slot: Option,
+ cancel: CancellationToken,
+}
+#[derive(Default)]
+struct State {
+ scope: Option,
+ grants: HashMap>,
+ disposed: HashSet,
+ cancelled: HashSet<(Binding, String)>,
+ calls: HashMap<(Binding, String), Call>,
+}
+#[derive(Default)]
+pub(super) struct Network(Mutex);
+pub(super) struct Request {
+ host: Arc,
+ key: (Binding, String),
+ pub cancel: CancellationToken,
+}
+impl Drop for Request {
+ fn drop(&mut self) {
+ if let Ok(mut state) = self.host.0.lock() {
+ state.calls.remove(&self.key);
+ }
+ }
+}
+impl Network {
+ fn current(state: &mut State, binding: &Binding) -> Result<()> {
+ match &state.scope {
+ Some(scope) if scope != &binding.token => return Err("workspace-changed".into()),
+ None => state.scope = Some(binding.token.clone()),
+ _ => {}
+ }
+ if state.disposed.len() >= 4096 || state.disposed.contains(binding) {
+ return Err("plugin-activation-ended".into());
+ }
+ Ok(())
+ }
+ pub fn scope(&self, token: &str) -> Result<()> {
+ let mut state = self.0.lock().map_err(|_| "plugin-network-error")?;
+ if state.scope.as_deref() != Some(token) {
+ for call in state.calls.values() {
+ call.cancel.cancel();
+ }
+ state.grants.clear();
+ state.disposed.clear();
+ state.cancelled.clear();
+ state.scope = Some(token.into());
+ }
+ Ok(())
+ }
+ pub fn authorize(&self, binding: &Binding, origin: &str) -> Result<()> {
+ if super::http::origin(origin)? != origin {
+ return Err("invalid-data".into());
+ }
+ let mut state = self.0.lock().map_err(|_| "plugin-network-error")?;
+ Self::current(&mut state, binding)?;
+ if !state.grants.contains_key(binding) && state.grants.len() >= 128 {
+ return Err("plugin-limit".into());
+ }
+ let grants = state.grants.entry(binding.clone()).or_default();
+ if !grants.contains(origin) && grants.len() >= 32 {
+ return Err("plugin-limit".into());
+ }
+ grants.insert(origin.into());
+ Ok(())
+ }
+ pub fn permissions(&self, binding: &Binding, declared: &Permissions) -> Result {
+ let mut state = self.0.lock().map_err(|_| "plugin-network-error")?;
+ Self::current(&mut state, binding)?;
+ let mut permissions = declared.clone();
+ if declared.network
+ && let Some(origins) = state.grants.get(binding)
+ {
+ for origin in origins {
+ if !permissions.origins.contains(origin) {
+ permissions.origins.push(origin.clone());
+ }
+ }
+ }
+ Ok(permissions)
+ }
+ pub fn register(self: &Arc, binding: &Binding, payload: &Value) -> Result {
+ let request = payload["requestId"]
+ .as_str()
+ .filter(|v| identifier(v))
+ .ok_or("invalid-data")?;
+ let mut state = self.0.lock().map_err(|_| "plugin-network-error")?;
+ Self::current(&mut state, binding)?;
+ let key = (binding.clone(), request.into());
+ if state.cancelled.contains(&key) {
+ return Err("plugin-request-cancelled".into());
+ }
+ if state.calls.contains_key(&key) {
+ return Err("plugin-request-conflict".into());
+ }
+ if state.calls.len() >= 32 {
+ return Err("plugin-limit".into());
+ }
+ let cancel = CancellationToken::new();
+ state.calls.insert(
+ key.clone(),
+ Call {
+ binding: binding.clone(),
+ slot: payload["secret"]["slot"].as_str().map(str::to_owned),
+ cancel: cancel.clone(),
+ },
+ );
+ Ok(Request {
+ host: self.clone(),
+ key,
+ cancel,
+ })
+ }
+ pub fn cancel(&self, binding: &Binding, request: &str) -> Result<()> {
+ if !identifier(request) {
+ return Err("invalid-data".into());
+ }
+ let mut state = self.0.lock().map_err(|_| "plugin-network-error")?;
+ Self::current(&mut state, binding)?;
+ let key = (binding.clone(), request.into());
+ if state.cancelled.len() < 4096 {
+ state.cancelled.insert(key.clone());
+ } else {
+ // Fail closed without an unbounded history when one activation repeatedly cancels.
+ if state.disposed.len() < 4096 {
+ state.disposed.insert(binding.clone());
+ }
+ state.grants.remove(binding);
+ for call in state.calls.values().filter(|call| &call.binding == binding) {
+ call.cancel.cancel();
+ }
+ }
+ if let Some(call) = state.calls.get(&key) {
+ call.cancel.cancel();
+ }
+ Ok(())
+ }
+ pub fn cancel_slot(&self, plugin: &str, slot: &str) {
+ if let Ok(state) = self.0.lock() {
+ for call in state
+ .calls
+ .values()
+ .filter(|call| call.binding.plugin == plugin && call.slot.as_deref() == Some(slot))
+ {
+ call.cancel.cancel();
+ }
+ }
+ }
+ pub fn dispose(&self, binding: &Binding) {
+ if let Ok(mut state) = self.0.lock() {
+ if state.disposed.len() < 4096 {
+ state.disposed.insert(binding.clone());
+ }
+ state.grants.remove(binding);
+ for call in state.calls.values().filter(|call| &call.binding == binding) {
+ call.cancel.cancel();
+ }
+ }
+ }
+ pub async fn run(
+ &self,
+ request: &Request,
+ identity: crate::workspaces::ActiveIdentity,
+ work: impl Future]
${escape(label)}
', bridge + '')
+ )
+ },
+ dispose() {
+ urls.clear()
+ },
+ }
+}
+export const emptyPreview = (label: string) =>
+ `