mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-17 20:46:01 +00:00
Port Nuvio-compatible scraper JS engine into fluxa_core
Adds a plugin_runtime module (rquickjs VM, cheerio-style DOM bridge via scraper/ego-tree, CryptoJS + WebCrypto polyfill backed by native digest/HMAC/PBKDF2/AES/RSA/ECDSA) ported from fluxa-desktop's plugin_runtime, gated behind a new plugin-js-engine feature. Unlike the desktop version, the in-script fetch() bridge never touches reqwest directly: it calls a new PluginHttpClient UniFFI callback interface (Box<dyn PluginHttpClient> -> Arc internally), so Kotlin/Tauri own the actual HTTP execution and any SSRF protections, consistent with fluxa_core never performing direct HTTP. execute_plugin_scraper is exposed as a synchronous UniFFI function alongside the existing core_invoke surface.
This commit is contained in:
parent
ca34306f86
commit
0d1acf1eb3
7 changed files with 3164 additions and 9 deletions
1166
Cargo.lock
generated
1166
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
41
Cargo.toml
41
Cargo.toml
|
|
@ -49,9 +49,10 @@ native = [
|
|||
"dep:jni",
|
||||
"dep:dolby_vision",
|
||||
"uniffi-bindings",
|
||||
"plugin-js-engine",
|
||||
]
|
||||
desktop = ["full-api"]
|
||||
ios = ["full-api", "uniffi-bindings"]
|
||||
desktop = ["full-api", "plugin-js-engine"]
|
||||
ios = ["full-api", "uniffi-bindings", "plugin-js-engine"]
|
||||
full-api = []
|
||||
streaming-shared = []
|
||||
uniffi-bindings = ["dep:uniffi", "full-api"]
|
||||
|
|
@ -59,16 +60,50 @@ uniffi-cli = ["uniffi-bindings", "uniffi/cli"]
|
|||
wasm = ["full-api", "dep:wasm-bindgen", "chrono/wasmbind"]
|
||||
fuzzing = []
|
||||
bench = []
|
||||
plugin-js-engine = [
|
||||
"dep:rquickjs",
|
||||
"dep:scraper",
|
||||
"dep:ego-tree",
|
||||
"dep:aes",
|
||||
"dep:aes-gcm",
|
||||
"dep:cbc",
|
||||
"dep:ecb",
|
||||
"dep:hmac",
|
||||
"dep:md-5",
|
||||
"dep:pbkdf2",
|
||||
"dep:rsa",
|
||||
"dep:p256",
|
||||
"dep:rand",
|
||||
"dep:sha1",
|
||||
"dep:log",
|
||||
"dep:tokio",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
aes = { version = "0.9.1", optional = true }
|
||||
aes-gcm = { version = "0.10", optional = true }
|
||||
base64 = "0.22"
|
||||
cbc = { version = "0.2.1", optional = true }
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
dolby_vision = { workspace = true, optional = true }
|
||||
ecb = { version = "0.2.0", optional = true }
|
||||
ego-tree = { version = "0.11", optional = true }
|
||||
hmac = { version = "0.13.0", optional = true }
|
||||
jni = { workspace = true, optional = true }
|
||||
log = { version = "0.4", optional = true }
|
||||
"md-5" = { version = "0.11.0", optional = true }
|
||||
p256 = { version = "0.14.0", optional = true }
|
||||
pbkdf2 = { version = "0.13.0", optional = true }
|
||||
rand = { version = "0.10.2", optional = true }
|
||||
regex = "1"
|
||||
sha2 = "0.10"
|
||||
rquickjs = { version = "0.12.1", features = ["full-async"], optional = true }
|
||||
rsa = { version = "0.9.10", features = ["sha2"], optional = true }
|
||||
scraper = { version = "0.27.0", optional = true }
|
||||
sha1 = { version = "0.11.0", optional = true }
|
||||
sha2 = "0.11.0"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { version = "1", features = ["rt", "macros", "time"], optional = true }
|
||||
uniffi = { version = "0.31.1", optional = true }
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
web-time = "1"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
use crate::{app_state, core_contract, headless_engine};
|
||||
#[cfg(feature = "plugin-js-engine")]
|
||||
use std::sync::Arc;
|
||||
|
||||
// A panic anywhere below must not unwind across the UniFFI boundary into
|
||||
// Swift/Kotlin — that's undefined behavior, not a catchable exception there.
|
||||
|
|
@ -105,3 +107,26 @@ pub fn app_core_dispatch_json(handle: i64, action_json: String) -> String {
|
|||
app_state::app_core_dispatch_json(handle as u64, &action_json).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "plugin-js-engine")]
|
||||
#[uniffi::export]
|
||||
pub fn execute_plugin_scraper(
|
||||
client: Box<dyn crate::plugin_runtime::PluginHttpClient>,
|
||||
code: String,
|
||||
tmdb_id: String,
|
||||
media_type: String,
|
||||
season: Option<i32>,
|
||||
episode: Option<i32>,
|
||||
) -> String {
|
||||
guard("[]".to_string(), || {
|
||||
crate::plugin_runtime::execute_scraper(
|
||||
Arc::from(client),
|
||||
code,
|
||||
tmdb_id,
|
||||
media_type,
|
||||
season,
|
||||
episode,
|
||||
)
|
||||
.unwrap_or_else(|_| "[]".to_string())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ mod player_policy;
|
|||
mod player_scrobble;
|
||||
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||
mod plugins;
|
||||
#[cfg(feature = "plugin-js-engine")]
|
||||
pub mod plugin_runtime;
|
||||
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||
mod profile_contract;
|
||||
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||
|
|
|
|||
415
src/plugin_runtime/crypto_bridge.rs
Normal file
415
src/plugin_runtime/crypto_bridge.rs
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
use aes::cipher::block_padding::{NoPadding, Pkcs7};
|
||||
use aes::cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyInit, KeyIvInit};
|
||||
use aes::{Aes128, Aes192, Aes256};
|
||||
use hmac::{Hmac, Mac};
|
||||
use md5::Md5;
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use rand::Rng;
|
||||
use rsa::pkcs1v15::{SigningKey as RsaSigningKey, VerifyingKey as RsaVerifyingKey};
|
||||
use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey};
|
||||
use rsa::signature::{SignatureEncoding, Signer, Verifier};
|
||||
use rsa::{RsaPrivateKey, RsaPublicKey};
|
||||
use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256, Sha384, Sha512};
|
||||
|
||||
pub fn digest(algorithm: &str, data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
Ok(match algorithm {
|
||||
"MD5" => Md5::digest(data).to_vec(),
|
||||
"SHA1" => Sha1::digest(data).to_vec(),
|
||||
"SHA256" => Sha256::digest(data).to_vec(),
|
||||
"SHA384" => Sha384::digest(data).to_vec(),
|
||||
"SHA512" => Sha512::digest(data).to_vec(),
|
||||
other => return Err(format!("unsupported digest algorithm: {other}")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hmac(algorithm: &str, key: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
macro_rules! hmac_with {
|
||||
($hash:ty) => {{
|
||||
let mut mac = Hmac::<$hash>::new_from_slice(key).map_err(|e| e.to_string())?;
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}};
|
||||
}
|
||||
Ok(match algorithm {
|
||||
"MD5" => hmac_with!(Md5),
|
||||
"SHA1" => hmac_with!(Sha1),
|
||||
"SHA256" => hmac_with!(Sha256),
|
||||
"SHA384" => hmac_with!(Sha384),
|
||||
"SHA512" => hmac_with!(Sha512),
|
||||
other => return Err(format!("unsupported HMAC algorithm: {other}")),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pbkdf2(
|
||||
password: &[u8],
|
||||
salt: &[u8],
|
||||
iterations: u32,
|
||||
key_size_bits: u32,
|
||||
algorithm: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let out_len = (key_size_bits as usize).div_ceil(8);
|
||||
let mut out = vec![0u8; out_len];
|
||||
macro_rules! pbkdf2_with {
|
||||
($hash:ty) => {
|
||||
pbkdf2_hmac::<$hash>(password, salt, iterations, &mut out)
|
||||
};
|
||||
}
|
||||
match algorithm {
|
||||
"MD5" => pbkdf2_with!(Md5),
|
||||
"SHA1" => pbkdf2_with!(Sha1),
|
||||
"SHA256" => pbkdf2_with!(Sha256),
|
||||
"SHA384" => pbkdf2_with!(Sha384),
|
||||
"SHA512" => pbkdf2_with!(Sha512),
|
||||
other => return Err(format!("unsupported PBKDF2 algorithm: {other}")),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn random_bytes(len: usize) -> Vec<u8> {
|
||||
let mut bytes = vec![0u8; len];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn aes_encrypt(mode: &str, key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let (base_mode, no_padding) = split_padding(mode);
|
||||
match base_mode {
|
||||
"AES-CBC" => cbc_encrypt(key, iv, data, no_padding),
|
||||
"AES-ECB" => ecb_encrypt(key, data, no_padding),
|
||||
"AES-GCM" => gcm_encrypt(key, iv, data),
|
||||
other => Err(format!("unsupported AES mode: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aes_decrypt(mode: &str, key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let (base_mode, no_padding) = split_padding(mode);
|
||||
match base_mode {
|
||||
"AES-CBC" => cbc_decrypt(key, iv, data, no_padding),
|
||||
"AES-ECB" => ecb_decrypt(key, data, no_padding),
|
||||
"AES-GCM" => gcm_decrypt(key, iv, data),
|
||||
other => Err(format!("unsupported AES mode: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_padding(mode: &str) -> (&str, bool) {
|
||||
match mode.strip_suffix("-NoPadding") {
|
||||
Some(base) => (base, true),
|
||||
None => (mode, false),
|
||||
}
|
||||
}
|
||||
|
||||
fn cbc_encrypt(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String> {
|
||||
if iv.len() != 16 {
|
||||
return Err("AES-CBC requires a 16-byte IV".to_string());
|
||||
}
|
||||
match key.len() {
|
||||
16 => cbc_encrypt_with::<Aes128>(key, iv, data, no_padding),
|
||||
24 => cbc_encrypt_with::<Aes192>(key, iv, data, no_padding),
|
||||
32 => cbc_encrypt_with::<Aes256>(key, iv, data, no_padding),
|
||||
other => Err(format!("unsupported AES key length: {other} bytes")),
|
||||
}
|
||||
}
|
||||
|
||||
fn cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String> {
|
||||
if iv.len() != 16 {
|
||||
return Err("AES-CBC requires a 16-byte IV".to_string());
|
||||
}
|
||||
match key.len() {
|
||||
16 => cbc_decrypt_with::<Aes128>(key, iv, data, no_padding),
|
||||
24 => cbc_decrypt_with::<Aes192>(key, iv, data, no_padding),
|
||||
32 => cbc_decrypt_with::<Aes256>(key, iv, data, no_padding),
|
||||
other => Err(format!("unsupported AES key length: {other} bytes")),
|
||||
}
|
||||
}
|
||||
|
||||
fn cbc_encrypt_with<C>(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String>
|
||||
where
|
||||
cbc::Encryptor<C>: KeyIvInit + BlockModeEncrypt,
|
||||
C: aes::cipher::BlockCipherEncrypt,
|
||||
{
|
||||
let encryptor = cbc::Encryptor::<C>::new_from_slices(key, iv).map_err(|e| e.to_string())?;
|
||||
let mut buf = data.to_vec();
|
||||
buf.resize(data.len() + 16, 0);
|
||||
let result = if no_padding {
|
||||
encryptor
|
||||
.encrypt_padded::<NoPadding>(&mut buf, data.len())
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
encryptor
|
||||
.encrypt_padded::<Pkcs7>(&mut buf, data.len())
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
Ok(result.to_vec())
|
||||
}
|
||||
|
||||
fn cbc_decrypt_with<C>(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String>
|
||||
where
|
||||
cbc::Decryptor<C>: KeyIvInit + BlockModeDecrypt,
|
||||
C: aes::cipher::BlockCipherDecrypt,
|
||||
{
|
||||
let decryptor = cbc::Decryptor::<C>::new_from_slices(key, iv).map_err(|e| e.to_string())?;
|
||||
let mut buf = data.to_vec();
|
||||
let result = if no_padding {
|
||||
decryptor
|
||||
.decrypt_padded::<NoPadding>(&mut buf)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
decryptor
|
||||
.decrypt_padded::<Pkcs7>(&mut buf)
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
Ok(result.to_vec())
|
||||
}
|
||||
|
||||
fn ecb_encrypt(key: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String> {
|
||||
match key.len() {
|
||||
16 => ecb_encrypt_with::<Aes128>(key, data, no_padding),
|
||||
24 => ecb_encrypt_with::<Aes192>(key, data, no_padding),
|
||||
32 => ecb_encrypt_with::<Aes256>(key, data, no_padding),
|
||||
other => Err(format!("unsupported AES key length: {other} bytes")),
|
||||
}
|
||||
}
|
||||
|
||||
fn ecb_decrypt(key: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String> {
|
||||
match key.len() {
|
||||
16 => ecb_decrypt_with::<Aes128>(key, data, no_padding),
|
||||
24 => ecb_decrypt_with::<Aes192>(key, data, no_padding),
|
||||
32 => ecb_decrypt_with::<Aes256>(key, data, no_padding),
|
||||
other => Err(format!("unsupported AES key length: {other} bytes")),
|
||||
}
|
||||
}
|
||||
|
||||
fn ecb_encrypt_with<C>(key: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String>
|
||||
where
|
||||
ecb::Encryptor<C>: aes::cipher::KeyInit + BlockModeEncrypt,
|
||||
C: aes::cipher::BlockCipherEncrypt,
|
||||
{
|
||||
let encryptor = ecb::Encryptor::<C>::new_from_slice(key).map_err(|e| e.to_string())?;
|
||||
let mut buf = data.to_vec();
|
||||
buf.resize(data.len() + 16, 0);
|
||||
let result = if no_padding {
|
||||
encryptor
|
||||
.encrypt_padded::<NoPadding>(&mut buf, data.len())
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
encryptor
|
||||
.encrypt_padded::<Pkcs7>(&mut buf, data.len())
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
Ok(result.to_vec())
|
||||
}
|
||||
|
||||
fn ecb_decrypt_with<C>(key: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String>
|
||||
where
|
||||
ecb::Decryptor<C>: aes::cipher::KeyInit + BlockModeDecrypt,
|
||||
C: aes::cipher::BlockCipherDecrypt,
|
||||
{
|
||||
let decryptor = ecb::Decryptor::<C>::new_from_slice(key).map_err(|e| e.to_string())?;
|
||||
let mut buf = data.to_vec();
|
||||
let result = if no_padding {
|
||||
decryptor
|
||||
.decrypt_padded::<NoPadding>(&mut buf)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
decryptor
|
||||
.decrypt_padded::<Pkcs7>(&mut buf)
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
Ok(result.to_vec())
|
||||
}
|
||||
|
||||
fn gcm_encrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
|
||||
|
||||
if iv.len() != 12 {
|
||||
return Err("AES-GCM requires a 12-byte IV".to_string());
|
||||
}
|
||||
let nonce = Nonce::from_slice(iv);
|
||||
match key.len() {
|
||||
16 => Aes128Gcm::new_from_slice(key)
|
||||
.map_err(|e| e.to_string())?
|
||||
.encrypt(nonce, data)
|
||||
.map_err(|e| e.to_string()),
|
||||
32 => Aes256Gcm::new_from_slice(key)
|
||||
.map_err(|e| e.to_string())?
|
||||
.encrypt(nonce, data)
|
||||
.map_err(|e| e.to_string()),
|
||||
other => Err(format!("unsupported AES-GCM key length: {other} bytes")),
|
||||
}
|
||||
}
|
||||
|
||||
fn gcm_decrypt(key: &[u8], iv: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use aes_gcm::aead::{Aead, KeyInit};
|
||||
use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
|
||||
|
||||
if iv.len() != 12 {
|
||||
return Err("AES-GCM requires a 12-byte IV".to_string());
|
||||
}
|
||||
let nonce = Nonce::from_slice(iv);
|
||||
match key.len() {
|
||||
16 => Aes128Gcm::new_from_slice(key)
|
||||
.map_err(|e| e.to_string())?
|
||||
.decrypt(nonce, data)
|
||||
.map_err(|e| e.to_string()),
|
||||
32 => Aes256Gcm::new_from_slice(key)
|
||||
.map_err(|e| e.to_string())?
|
||||
.decrypt(nonce, data)
|
||||
.map_err(|e| e.to_string()),
|
||||
other => Err(format!("unsupported AES-GCM key length: {other} bytes")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sign(algorithm: &str, private_key_der: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if let Some(hash) = algorithm.strip_prefix("RSASSA-PKCS1-V1_5-") {
|
||||
return rsa_sign(hash, private_key_der, data);
|
||||
}
|
||||
if algorithm.starts_with("ECDSA") {
|
||||
return ecdsa_sign(private_key_der, data);
|
||||
}
|
||||
Err(format!("unsupported signature algorithm: {algorithm}"))
|
||||
}
|
||||
|
||||
pub fn verify(algorithm: &str, public_key_der: &[u8], signature: &[u8], data: &[u8]) -> Result<bool, String> {
|
||||
if let Some(hash) = algorithm.strip_prefix("RSASSA-PKCS1-V1_5-") {
|
||||
return rsa_verify(hash, public_key_der, signature, data);
|
||||
}
|
||||
if algorithm.starts_with("ECDSA") {
|
||||
return ecdsa_verify(public_key_der, signature, data);
|
||||
}
|
||||
Err(format!("unsupported signature algorithm: {algorithm}"))
|
||||
}
|
||||
|
||||
fn rsa_sign(hash: &str, private_key_der: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use rsa::sha2::{Sha256, Sha384, Sha512};
|
||||
|
||||
let key = RsaPrivateKey::from_pkcs8_der(private_key_der).map_err(|e| e.to_string())?;
|
||||
macro_rules! sign_with {
|
||||
($d:ty) => {{
|
||||
let signing_key = RsaSigningKey::<$d>::new(key);
|
||||
signing_key.sign(data).to_vec()
|
||||
}};
|
||||
}
|
||||
Ok(match hash {
|
||||
"SHA256" => sign_with!(Sha256),
|
||||
"SHA384" => sign_with!(Sha384),
|
||||
"SHA512" => sign_with!(Sha512),
|
||||
other => return Err(format!("unsupported RSA signature hash: {other}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn rsa_verify(hash: &str, public_key_der: &[u8], signature: &[u8], data: &[u8]) -> Result<bool, String> {
|
||||
use rsa::sha2::{Sha256, Sha384, Sha512};
|
||||
|
||||
let key = RsaPublicKey::from_public_key_der(public_key_der).map_err(|e| e.to_string())?;
|
||||
macro_rules! verify_with {
|
||||
($d:ty) => {{
|
||||
let verifying_key = RsaVerifyingKey::<$d>::new(key);
|
||||
let sig = rsa::pkcs1v15::Signature::try_from(signature).map_err(|e| e.to_string())?;
|
||||
verifying_key.verify(data, &sig).is_ok()
|
||||
}};
|
||||
}
|
||||
Ok(match hash {
|
||||
"SHA256" => verify_with!(Sha256),
|
||||
"SHA384" => verify_with!(Sha384),
|
||||
"SHA512" => verify_with!(Sha512),
|
||||
other => return Err(format!("unsupported RSA signature hash: {other}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn ecdsa_sign(private_key_der: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use p256::ecdsa::signature::Signer as EcdsaSigner;
|
||||
use p256::ecdsa::{Signature, SigningKey};
|
||||
use p256::pkcs8::DecodePrivateKey as EcdsaDecodePrivateKey;
|
||||
|
||||
let signing_key =
|
||||
SigningKey::from_pkcs8_der(private_key_der).map_err(|e| e.to_string())?;
|
||||
let signature: Signature = signing_key.sign(data);
|
||||
Ok(signature.to_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn ecdsa_verify(public_key_der: &[u8], signature: &[u8], data: &[u8]) -> Result<bool, String> {
|
||||
use p256::ecdsa::signature::Verifier as EcdsaVerifier;
|
||||
use p256::ecdsa::{Signature, VerifyingKey};
|
||||
use p256::pkcs8::DecodePublicKey as EcdsaDecodePublicKey;
|
||||
|
||||
let verifying_key =
|
||||
VerifyingKey::from_public_key_der(public_key_der).map_err(|e| e.to_string())?;
|
||||
let sig = Signature::from_slice(signature).map_err(|e| e.to_string())?;
|
||||
Ok(verifying_key.verify(data, &sig).is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hmac_sha256_output_is_32_bytes() {
|
||||
let mac = hmac("SHA256", b"key", b"The quick brown fox jumps over the lazy dog").unwrap();
|
||||
assert_eq!(mac.len(), 32, "HMAC-SHA256 must be 32 bytes, got {}", mac.len());
|
||||
let hex: String = mac.iter().map(|b| format!("{b:02x}")).collect();
|
||||
assert_eq!(
|
||||
hex,
|
||||
"f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
|
||||
);
|
||||
}
|
||||
|
||||
fn hex_decode(s: &str) -> Vec<u8> {
|
||||
(0..s.len() / 2)
|
||||
.map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
const RSA_PRIV_DER_HEX: &str = "308204bd020100300d06092a864886f70d0101010500048204a7308204a30201000282010100bddba8f4e0cb99a11283571767832821a23220b11ec92160e3a99185a7c11342abc002d1b981f2fa73beb88b682e95a06d51cf0ecc82bd6a33ab0d778d1063d3229711313c5350d984b3bce669ddbebabfe34e261588cb6c560edb6d7f83a403e492ef387d8119cbc2f2f695dca535fed5df1fc54001ddb8712b895d90fbd9bd43d3e4b2cf2512f6c6e88d99e711883039d108bfb80e1882b52d0c869740f0c2106c706bd2fb003a50a18414c135042b97ba70874bc5d4100437be9a375ac85c48c768cdfbdc958c0f7a7c3b18cd2de9ef2d5241e1406e0f672f17199fd4c94d6179991f1b1860fa1d13a9a4adcd1ef1a261a4241e5f0e78feaf0ebb54408b9702030100010282010018f74bae77e90e18ba407f315ab14a93b1aa79d610c04e1d5992b0ca2c971d1b454e88905f9e90ff123d603324ce0b6aff3524cf436165db135448ff1193b82cc97da80b1b6db152093b4179e32dffa8bb16feedcf231feced1c230ab11bf473769943cfdfe11c2d49437ddd93183e937638530b33318ba077714c2cfba5c2aa00b2b92c6c6c0d845d689aa3d0745d38b799a6ee65c9c2b601466d350faa03dc089ecd282eca2300bcaebd8ca90fb56136d526dc4e88381e137d07db846b099a3960f418440a2ba90bdbb57bdba68cec424d25524f6d4b3e6fb2b0afe60bc04b6b94e5eb5c94b4b0f17a2a068cabf852af771968958e69bb57d6b12b92d11f4102818100e898d69b8b669ce41491374ae51a4d7ed8d411424b58b0dffe7e10603070782c7600fa69cd86ddaf4bffa4aa84e53f8776c298f3ae246c2072e089688b550757f6129842be9a1fde12d880c8185bf8321ed0175bd3a148e4f7bf29b6e58f2d28003a5e8d9084c9258128591ac99f5a4e5aa8bf3911606b036f344ab8790cce3102818100d0f5f6e2fed04943dc6f37ff2fd34ba6f525a5ae1eb2a838c8f81ae1666d082d9ed9cafec15c146fd141fa2ae2c4da6ad9c88f77e399b905bd54bb62db0f23e5e02fedcc0d5c9bf699cf829efdc1b1af0b512e51dc7105ce15565e8b948e7f1e6eb945d94b5c99bdf7f8fe697deaf60c43cebb38abf2996d13d85de066061c4702818100bf07afd266062bbac30d952ce4c6da78e09a2366a03d425f6543a22d6dfb2127daeee8bb76ba5a5dbb7c45806acf6182df4de992d12646cbcbad900d15ca0ad2fa5dc141a80b2b3b179d4aff8bca15290cd77927c8c340fc1461a35ffc5fc0058752690fb96ee548f12ff326a60b437ef0c480f0dccda47658a1c8f08332ba3102818038589ae24ae3cf5dc52b18666f7702875ac37411a7a575471aaa0c558b510b4b81f22892d98102e1c4038e79d1fc53094d008fef3c284f8404b1bc75c3ac2979eb2201756c84f46962c059f0956c8d852e000e0574f3e524d50c1d22764496729658cf44f0d55f7fa2463a1428281b80e327f3c42c5996959b2c54e4159ec0310281800bbc5ab68bdd62ebe027a1e86e46b9cfdd888c0e6e733e9c8e41a5dd4bb743583671cab340e0372b3d21a59d6dd903ec8a2a21473317e4bc99c54ac1647f1169f624da78dfc23eff30971c0f0ca3e108ea7a93027e9721ed9d8631c76a73aed28592e593b47bfd39c6f3cf5278dcbf7e127ed2024427f4836dbd0a64839dd223";
|
||||
const RSA_PUB_DER_HEX: &str = "30820122300d06092a864886f70d01010105000382010f003082010a0282010100bddba8f4e0cb99a11283571767832821a23220b11ec92160e3a99185a7c11342abc002d1b981f2fa73beb88b682e95a06d51cf0ecc82bd6a33ab0d778d1063d3229711313c5350d984b3bce669ddbebabfe34e261588cb6c560edb6d7f83a403e492ef387d8119cbc2f2f695dca535fed5df1fc54001ddb8712b895d90fbd9bd43d3e4b2cf2512f6c6e88d99e711883039d108bfb80e1882b52d0c869740f0c2106c706bd2fb003a50a18414c135042b97ba70874bc5d4100437be9a375ac85c48c768cdfbdc958c0f7a7c3b18cd2de9ef2d5241e1406e0f672f17199fd4c94d6179991f1b1860fa1d13a9a4adcd1ef1a261a4241e5f0e78feaf0ebb54408b970203010001";
|
||||
const RSA_SIG_HEX: &str = "1f6e08bb7841498b47f553cb6a3c4a09a177169bdeb6152634720f63ec7348e04c1e07b2915c1999bcb4bdbfbc85f970b641b4a6295e2befda427b51ab2cbd6c2fceaf57e0854c0e1c943802539c050026a89e1fcefe35fd4a6d8ab5cf8955d4f9da1e8713686d038ebda8a9623b1c18cfc2f5d30a3846c6c133fe50930ca03c3b3645999d0eb89056406c5746dd6635dd45ebbcdecb6e48e6853d1594bd41bda6017c6cc2b053df7a791d8c8822528b42a6e554e21ad86bb800c9b298dff248ad807233bc0c95e6db63d98d92926e328f78a5f9a2495d319ba6d43c594107e2829d3fe2b09da6fbbd5e398f57fdc5f0975b78e8aceeb9f63a474bad6fc89198";
|
||||
const RSA_SIGNED_MESSAGE: &[u8] = b"hello plugin signature test";
|
||||
|
||||
const EC_PRIV_DER_HEX: &str = "308187020100301306072a8648ce3d020106082a8648ce3d030107046d306b020101042042f8c39da6ffb2b063b1fc24f521c8588064773e0f46c0c1c515759ca3a80f3aa144034200049e6a723242258b9d87c8362fd321140b80e16d1671b5cb9b2dba3da7ccc42b82380102e3fd415ed40dfc8c4b4b218218995327daedc7eb35493f4f5b419aeaf8";
|
||||
const EC_PUB_DER_HEX: &str = "3059301306072a8648ce3d020106082a8648ce3d030107034200049e6a723242258b9d87c8362fd321140b80e16d1671b5cb9b2dba3da7ccc42b82380102e3fd415ed40dfc8c4b4b218218995327daedc7eb35493f4f5b419aeaf8";
|
||||
const EC_SIG_HEX: &str = "f982b6fd52964b591329f5b503627dc1dc5b7f74ff0cf9acc840ab160636a99a526a7f11ee179e77d176827ab0035ee92653e3e7408c6c8fea3f566ec79e8c8f";
|
||||
const EC_SIGNED_MESSAGE: &[u8] = b"hello plugin signature test";
|
||||
|
||||
#[test]
|
||||
fn rsa_verify_accepts_an_openssl_produced_signature() {
|
||||
let key = hex_decode(RSA_PUB_DER_HEX);
|
||||
let sig = hex_decode(RSA_SIG_HEX);
|
||||
let ok = verify("RSASSA-PKCS1-V1_5-SHA256", &key, &sig, RSA_SIGNED_MESSAGE).unwrap();
|
||||
assert!(ok, "should verify a real openssl-produced RSA signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_verify_rejects_a_tampered_message() {
|
||||
let key = hex_decode(RSA_PUB_DER_HEX);
|
||||
let sig = hex_decode(RSA_SIG_HEX);
|
||||
let ok = verify("RSASSA-PKCS1-V1_5-SHA256", &key, &sig, b"tampered message").unwrap();
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rsa_sign_then_openssl_style_verify_roundtrips() {
|
||||
let priv_key = hex_decode(RSA_PRIV_DER_HEX);
|
||||
let pub_key = hex_decode(RSA_PUB_DER_HEX);
|
||||
let sig = sign("RSASSA-PKCS1-V1_5-SHA256", &priv_key, RSA_SIGNED_MESSAGE).unwrap();
|
||||
let ok = verify("RSASSA-PKCS1-V1_5-SHA256", &pub_key, &sig, RSA_SIGNED_MESSAGE).unwrap();
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecdsa_verify_accepts_a_python_cryptography_produced_signature() {
|
||||
let key = hex_decode(EC_PUB_DER_HEX);
|
||||
let sig = hex_decode(EC_SIG_HEX);
|
||||
let ok = verify("ECDSA-SHA256", &key, &sig, EC_SIGNED_MESSAGE).unwrap();
|
||||
assert!(ok, "should verify a real python `cryptography`-produced P-256 signature");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ecdsa_sign_then_verify_roundtrips() {
|
||||
let priv_key = hex_decode(EC_PRIV_DER_HEX);
|
||||
let pub_key = hex_decode(EC_PUB_DER_HEX);
|
||||
let sig = sign("ECDSA-SHA256", &priv_key, EC_SIGNED_MESSAGE).unwrap();
|
||||
let ok = verify("ECDSA-SHA256", &pub_key, &sig, EC_SIGNED_MESSAGE).unwrap();
|
||||
assert!(ok);
|
||||
}
|
||||
}
|
||||
210
src/plugin_runtime/dom_bridge.rs
Normal file
210
src/plugin_runtime/dom_bridge.rs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
use ego_tree::NodeId;
|
||||
use scraper::{ElementRef, Html, Selector};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Default)]
|
||||
struct DomState {
|
||||
documents: HashMap<String, Html>,
|
||||
elements: HashMap<String, (String, NodeId)>,
|
||||
counter: u64,
|
||||
}
|
||||
|
||||
impl DomState {
|
||||
fn next_id(&mut self) -> u64 {
|
||||
let id = self.counter;
|
||||
self.counter += 1;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DomBridge {
|
||||
state: RefCell<DomState>,
|
||||
}
|
||||
|
||||
impl DomBridge {
|
||||
pub fn new() -> Rc<Self> {
|
||||
Rc::new(Self {
|
||||
state: RefCell::new(DomState::default()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(&self, html: String) -> String {
|
||||
let mut state = self.state.borrow_mut();
|
||||
let doc_id = format!("doc_{}", state.next_id());
|
||||
state.documents.insert(doc_id.clone(), Html::parse_document(&html));
|
||||
doc_id
|
||||
}
|
||||
|
||||
pub fn select(&self, doc_id: String, selector: String) -> String {
|
||||
let mut state = self.state.borrow_mut();
|
||||
if selector.is_empty() {
|
||||
return "[]".into();
|
||||
}
|
||||
let ids: Vec<NodeId> = match state.documents.get(&doc_id) {
|
||||
Some(doc) => run_selector(doc, &selector).iter().map(|el| el.id()).collect(),
|
||||
None => return "[]".into(),
|
||||
};
|
||||
cache_and_encode(&mut state, &doc_id, ids)
|
||||
}
|
||||
|
||||
pub fn find(&self, doc_id: String, element_id: String, selector: String) -> String {
|
||||
let mut state = self.state.borrow_mut();
|
||||
let scope = match state.elements.get(&element_id) {
|
||||
Some((_, node_id)) => *node_id,
|
||||
None => return "[]".into(),
|
||||
};
|
||||
let ids: Vec<NodeId> = match state.documents.get(&doc_id) {
|
||||
Some(doc) => run_selector_within(doc, scope, &selector)
|
||||
.iter()
|
||||
.map(|el| el.id())
|
||||
.collect(),
|
||||
None => return "[]".into(),
|
||||
};
|
||||
cache_and_encode(&mut state, &doc_id, ids)
|
||||
}
|
||||
|
||||
pub fn text(&self, element_ids_csv: String) -> String {
|
||||
let state = self.state.borrow();
|
||||
element_ids_csv
|
||||
.split(',')
|
||||
.filter(|id| !id.is_empty())
|
||||
.filter_map(|id| resolve(&state, id))
|
||||
.map(|el| el.text().collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn html(&self, doc_id: String, element_id: String) -> String {
|
||||
let state = self.state.borrow();
|
||||
if element_id.is_empty() {
|
||||
state.documents.get(&doc_id).map(|d| d.html()).unwrap_or_default()
|
||||
} else {
|
||||
resolve(&state, &element_id).map(|el| el.inner_html()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inner_html(&self, element_id: String) -> String {
|
||||
let state = self.state.borrow();
|
||||
resolve(&state, &element_id).map(|el| el.inner_html()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn attr(&self, element_id: String, attr_name: String) -> String {
|
||||
let state = self.state.borrow();
|
||||
match resolve(&state, &element_id).and_then(|el| el.attr(&attr_name)) {
|
||||
Some(value) if !value.is_empty() => value.to_string(),
|
||||
_ => "__UNDEFINED__".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&self, doc_id: String, element_id: String) -> String {
|
||||
self.sibling(&doc_id, &element_id, true)
|
||||
}
|
||||
|
||||
pub fn prev(&self, doc_id: String, element_id: String) -> String {
|
||||
self.sibling(&doc_id, &element_id, false)
|
||||
}
|
||||
|
||||
fn sibling(&self, doc_id: &str, element_id: &str, forward: bool) -> String {
|
||||
let mut state = self.state.borrow_mut();
|
||||
let start = match state.elements.get(element_id) {
|
||||
Some((_, node_id)) => *node_id,
|
||||
None => return "__NONE__".into(),
|
||||
};
|
||||
|
||||
let found = match state.documents.get(doc_id) {
|
||||
Some(doc) => next_element_sibling_id(doc, start, forward),
|
||||
None => return "__NONE__".into(),
|
||||
};
|
||||
|
||||
match found {
|
||||
Some(node_id) => {
|
||||
let element_id = format!("{doc_id}:{}", state.next_id());
|
||||
state.elements.insert(element_id.clone(), (doc_id.to_string(), node_id));
|
||||
element_id
|
||||
}
|
||||
None => "__NONE__".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve<'a>(state: &'a DomState, element_id: &str) -> Option<ElementRef<'a>> {
|
||||
let (doc_id, node_id) = state.elements.get(element_id)?;
|
||||
let doc = state.documents.get(doc_id)?;
|
||||
ElementRef::wrap(doc.tree.get(*node_id)?)
|
||||
}
|
||||
|
||||
fn next_element_sibling_id(doc: &Html, from: NodeId, forward: bool) -> Option<NodeId> {
|
||||
let mut cursor = doc.tree.get(from);
|
||||
loop {
|
||||
let node_ref = cursor?;
|
||||
let sib = if forward {
|
||||
node_ref.next_sibling()
|
||||
} else {
|
||||
node_ref.prev_sibling()
|
||||
};
|
||||
let sib = sib?;
|
||||
let sib_id = sib.id();
|
||||
if ElementRef::wrap(sib).is_some() {
|
||||
return Some(sib_id);
|
||||
}
|
||||
cursor = doc.tree.get(sib_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn split_contains(selector: &str) -> (String, Option<String>) {
|
||||
let Some(start) = selector.find(":contains(") else {
|
||||
return (selector.to_string(), None);
|
||||
};
|
||||
let after = &selector[start + ":contains(".len()..];
|
||||
let Some(end) = after.find(')') else {
|
||||
return (selector.to_string(), None);
|
||||
};
|
||||
let needle = after[..end].trim_matches(|c| c == '\'' || c == '"').to_string();
|
||||
let mut base = String::new();
|
||||
base.push_str(&selector[..start]);
|
||||
base.push_str(&after[end + 1..]);
|
||||
let base = base.trim();
|
||||
let base = if base.is_empty() { "*".to_string() } else { base.to_string() };
|
||||
(base, Some(needle))
|
||||
}
|
||||
|
||||
fn run_selector<'a>(doc: &'a Html, selector: &str) -> Vec<ElementRef<'a>> {
|
||||
let (base, needle) = split_contains(selector);
|
||||
let Ok(sel) = Selector::parse(&base) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut results: Vec<ElementRef<'a>> = doc.select(&sel).collect();
|
||||
if let Some(needle) = needle {
|
||||
results.retain(|el| el.text().collect::<String>().contains(&needle));
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn run_selector_within<'a>(doc: &'a Html, scope: NodeId, selector: &str) -> Vec<ElementRef<'a>> {
|
||||
let (base, needle) = split_contains(selector);
|
||||
let Ok(sel) = Selector::parse(&base) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(scope_ref) = doc.tree.get(scope).and_then(ElementRef::wrap) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut results: Vec<ElementRef<'a>> = scope_ref.select(&sel).collect();
|
||||
if let Some(needle) = needle {
|
||||
results.retain(|el| el.text().collect::<String>().contains(&needle));
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn cache_and_encode(state: &mut DomState, doc_id: &str, ids: Vec<NodeId>) -> String {
|
||||
let encoded: Vec<String> = ids
|
||||
.into_iter()
|
||||
.map(|node_id| {
|
||||
let element_id = format!("{doc_id}:{}", state.next_id());
|
||||
state.elements.insert(element_id.clone(), (doc_id.to_string(), node_id));
|
||||
element_id
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&encoded).unwrap_or_else(|_| "[]".into())
|
||||
}
|
||||
1314
src/plugin_runtime/mod.rs
Normal file
1314
src/plugin_runtime/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue