feat(wfe-yaml): add HTTP ops, module loader, and npm support via esm.sh
Phase 4 — Permission-gated HTTP fetch op: - op_fetch with net permission check on every request - globalThis.fetch() wrapper with .json()/.text() methods - Supports GET/POST/PUT/DELETE with headers and body Phase 5 — Module loader: - WfeModuleLoader resolving npm: → esm.sh, https://, file://, relative paths - All resolution paths permission-checked - Bare path resolution (/) for esm.sh sub-module redirects - Dynamic import rejection unless permissions.dynamic_import: true - esm.sh auto-added to net allowlist when modules declared Mandatory npm integration test (is-number via esm.sh). 25 new tests. 133 total deno tests, 326 total workspace tests.
This commit is contained in:
391
wfe-yaml/src/executors/deno/module_loader.rs
Normal file
391
wfe-yaml/src/executors/deno/module_loader.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use deno_core::{
|
||||
ModuleLoadOptions, ModuleLoadReferrer, ModuleLoadResponse, ModuleLoader, ModuleSource,
|
||||
ModuleSourceCode, ModuleSpecifier, ModuleType, ResolutionKind,
|
||||
};
|
||||
use deno_error::JsErrorBox;
|
||||
|
||||
use super::permissions::PermissionChecker;
|
||||
|
||||
/// Custom module loader that enforces permissions and rewrites npm: specifiers.
|
||||
pub struct WfeModuleLoader {
|
||||
permissions: Rc<RefCell<PermissionChecker>>,
|
||||
}
|
||||
|
||||
impl WfeModuleLoader {
|
||||
pub fn new(permissions: Rc<RefCell<PermissionChecker>>) -> Self {
|
||||
Self { permissions }
|
||||
}
|
||||
|
||||
/// Rewrite `npm:package@version` to `https://esm.sh/package@version`.
|
||||
pub fn resolve_npm_specifier(specifier: &str) -> Option<String> {
|
||||
specifier
|
||||
.strip_prefix("npm:")
|
||||
.map(|rest| format!("https://esm.sh/{rest}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl ModuleLoader for WfeModuleLoader {
|
||||
fn resolve(
|
||||
&self,
|
||||
specifier: &str,
|
||||
referrer: &str,
|
||||
kind: ResolutionKind,
|
||||
) -> Result<ModuleSpecifier, JsErrorBox> {
|
||||
// Block dynamic imports if not allowed.
|
||||
if matches!(kind, ResolutionKind::DynamicImport) {
|
||||
let checker = self.permissions.borrow();
|
||||
if !checker.allow_dynamic_import {
|
||||
return Err(JsErrorBox::generic(
|
||||
"Dynamic import is not allowed by permissions",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ext: URLs are handled by deno_core.
|
||||
if specifier.starts_with("ext:") {
|
||||
return ModuleSpecifier::parse(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid ext URL: {e}")));
|
||||
}
|
||||
|
||||
// npm: specifier -> rewrite to esm.sh URL.
|
||||
if let Some(esm_url) = Self::resolve_npm_specifier(specifier) {
|
||||
let parsed = ModuleSpecifier::parse(&esm_url)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid esm.sh URL: {e}")))?;
|
||||
return Ok(parsed);
|
||||
}
|
||||
|
||||
// wfe: scheme for inline modules (used by load_main_es_module_from_code).
|
||||
if specifier.starts_with("wfe:") {
|
||||
return ModuleSpecifier::parse(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid wfe URL: {e}")));
|
||||
}
|
||||
|
||||
// Absolute file:// URL.
|
||||
if specifier.starts_with("file://") {
|
||||
let parsed = ModuleSpecifier::parse(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid file URL: {e}")))?;
|
||||
let path = parsed
|
||||
.to_file_path()
|
||||
.map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"))?;
|
||||
let checker = self.permissions.borrow();
|
||||
checker
|
||||
.check_read(
|
||||
path.to_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("Non-UTF8 path"))?,
|
||||
)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
return Ok(parsed);
|
||||
}
|
||||
|
||||
// Absolute https:// URL.
|
||||
if specifier.starts_with("https://") || specifier.starts_with("http://") {
|
||||
let parsed = ModuleSpecifier::parse(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid URL: {e}")))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("URL has no host"))?;
|
||||
let checker = self.permissions.borrow();
|
||||
checker
|
||||
.check_net(host)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
return Ok(parsed);
|
||||
}
|
||||
|
||||
// Relative or bare path — resolve against referrer.
|
||||
// This handles ./foo, ../foo, and /foo (absolute path on same origin, e.g. esm.sh redirects)
|
||||
if specifier.starts_with("./")
|
||||
|| specifier.starts_with("../")
|
||||
|| specifier.starts_with('/')
|
||||
{
|
||||
let base = ModuleSpecifier::parse(referrer)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Invalid referrer '{referrer}': {e}")))?;
|
||||
let resolved = base
|
||||
.join(specifier)
|
||||
.map_err(|e| JsErrorBox::generic(format!("Failed to resolve '{specifier}': {e}")))?;
|
||||
|
||||
// Check permissions based on scheme.
|
||||
match resolved.scheme() {
|
||||
"file" => {
|
||||
let path = resolved
|
||||
.to_file_path()
|
||||
.map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"))?;
|
||||
let checker = self.permissions.borrow();
|
||||
checker
|
||||
.check_read(
|
||||
path.to_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("Non-UTF8 path"))?,
|
||||
)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
}
|
||||
"https" | "http" => {
|
||||
let host = resolved
|
||||
.host_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("URL has no host"))?;
|
||||
let checker = self.permissions.borrow();
|
||||
checker
|
||||
.check_net(host)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
Err(JsErrorBox::generic(format!(
|
||||
"Unsupported module specifier: '{specifier}'"
|
||||
)))
|
||||
}
|
||||
|
||||
fn load(
|
||||
&self,
|
||||
module_specifier: &ModuleSpecifier,
|
||||
_maybe_referrer: Option<&ModuleLoadReferrer>,
|
||||
_options: ModuleLoadOptions,
|
||||
) -> ModuleLoadResponse {
|
||||
let specifier = module_specifier.clone();
|
||||
let permissions = self.permissions.clone();
|
||||
|
||||
match specifier.scheme() {
|
||||
"ext" => {
|
||||
// Handled by deno_core; should not reach here.
|
||||
ModuleLoadResponse::Sync(Err(JsErrorBox::generic(
|
||||
"ext: modules are handled by deno_core",
|
||||
)))
|
||||
}
|
||||
"https" | "http" => {
|
||||
// Fetch over network.
|
||||
let url = specifier.to_string();
|
||||
ModuleLoadResponse::Async(Box::pin(async move {
|
||||
// Permission check on the host.
|
||||
let host = specifier
|
||||
.host_str()
|
||||
.ok_or_else(|| JsErrorBox::generic("URL has no host"))?
|
||||
.to_string();
|
||||
{
|
||||
let checker = permissions.borrow();
|
||||
checker
|
||||
.check_net(&host)
|
||||
.map_err(|e| JsErrorBox::new("PermissionError", e.to_string()))?;
|
||||
}
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
JsErrorBox::generic(format!("Failed to fetch module '{url}': {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(JsErrorBox::generic(format!(
|
||||
"Failed to fetch module '{url}': HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let code: String = response.text().await.map_err(|e| {
|
||||
JsErrorBox::generic(format!("Failed to read module body '{url}': {e}"))
|
||||
})?;
|
||||
|
||||
Ok(ModuleSource::new(
|
||||
ModuleType::JavaScript,
|
||||
ModuleSourceCode::String(code.into()),
|
||||
&specifier,
|
||||
None,
|
||||
))
|
||||
}))
|
||||
}
|
||||
"file" => {
|
||||
// Read from disk.
|
||||
let path_result = specifier
|
||||
.to_file_path()
|
||||
.map_err(|_| JsErrorBox::generic("Cannot convert URL to file path"));
|
||||
match path_result {
|
||||
Ok(path) => {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
|
||||
// Permission check.
|
||||
{
|
||||
let checker = permissions.borrow();
|
||||
if let Err(e) = checker.check_read(&path_str) {
|
||||
return ModuleLoadResponse::Sync(Err(JsErrorBox::new(
|
||||
"PermissionError",
|
||||
e.to_string(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(code) => ModuleLoadResponse::Sync(Ok(ModuleSource::new(
|
||||
ModuleType::JavaScript,
|
||||
ModuleSourceCode::String(code.into()),
|
||||
&specifier,
|
||||
None,
|
||||
))),
|
||||
Err(e) => ModuleLoadResponse::Sync(Err(JsErrorBox::generic(
|
||||
format!("Failed to read module '{}': {e}", path.display()),
|
||||
))),
|
||||
}
|
||||
}
|
||||
Err(e) => ModuleLoadResponse::Sync(Err(e)),
|
||||
}
|
||||
}
|
||||
scheme => ModuleLoadResponse::Sync(Err(JsErrorBox::generic(format!(
|
||||
"Unsupported module scheme: '{scheme}'"
|
||||
)))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::executors::deno::config::DenoPermissions;
|
||||
|
||||
fn make_loader(perms: DenoPermissions) -> WfeModuleLoader {
|
||||
let checker = PermissionChecker::from_config(&perms);
|
||||
WfeModuleLoader::new(Rc::new(RefCell::new(checker)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_npm_to_esm_sh() {
|
||||
let result = WfeModuleLoader::resolve_npm_specifier("npm:lodash@4.17.21");
|
||||
assert_eq!(result, Some("https://esm.sh/lodash@4.17.21".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_npm_scoped_package() {
|
||||
let result = WfeModuleLoader::resolve_npm_specifier("npm:@org/pkg@1.0.0");
|
||||
assert_eq!(result, Some("https://esm.sh/@org/pkg@1.0.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_non_npm_returns_none() {
|
||||
assert!(WfeModuleLoader::resolve_npm_specifier("https://example.com/mod.js").is_none());
|
||||
assert!(WfeModuleLoader::resolve_npm_specifier("./local.js").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_npm_specifier_via_loader() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
net: vec!["esm.sh".to_string()],
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader
|
||||
.resolve("npm:lodash@4", "ext:wfe/bootstrap.js", ResolutionKind::Import)
|
||||
.unwrap();
|
||||
assert_eq!(result.as_str(), "https://esm.sh/lodash@4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_https_with_permission() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
net: vec!["cdn.example.com".to_string()],
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader
|
||||
.resolve(
|
||||
"https://cdn.example.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::Import,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.as_str(), "https://cdn.example.com/mod.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_https_without_permission_fails() {
|
||||
let loader = make_loader(DenoPermissions::default());
|
||||
let result = loader.resolve(
|
||||
"https://evil.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::Import,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Permission denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dynamic_import_denied() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
net: vec!["cdn.example.com".to_string()],
|
||||
dynamic_import: false,
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader.resolve(
|
||||
"https://cdn.example.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::DynamicImport,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Dynamic import is not allowed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_dynamic_import_allowed() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
net: vec!["cdn.example.com".to_string()],
|
||||
dynamic_import: true,
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader.resolve(
|
||||
"https://cdn.example.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::DynamicImport,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_ext_url() {
|
||||
let loader = make_loader(DenoPermissions::default());
|
||||
let result = loader
|
||||
.resolve(
|
||||
"ext:wfe/bootstrap.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::Import,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.as_str(), "ext:wfe/bootstrap.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_relative_from_file_referrer() {
|
||||
let loader = make_loader(DenoPermissions {
|
||||
read: vec!["/tmp".to_string()],
|
||||
..Default::default()
|
||||
});
|
||||
let result = loader
|
||||
.resolve(
|
||||
"./helper.js",
|
||||
"file:///tmp/main.js",
|
||||
ResolutionKind::Import,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.as_str(), "file:///tmp/helper.js");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_relative_without_read_permission_fails() {
|
||||
let loader = make_loader(DenoPermissions::default());
|
||||
let result = loader.resolve("./helper.js", "file:///tmp/main.js", ResolutionKind::Import);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_unsupported_specifier() {
|
||||
let loader = make_loader(DenoPermissions::default());
|
||||
let result = loader.resolve(
|
||||
"ftp://bad.com/mod.js",
|
||||
"ext:wfe/bootstrap.js",
|
||||
ResolutionKind::Import,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Unsupported"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user