use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use deno_ast::{MediaType, ParseParams};
use deno_core::anyhow::anyhow;
use deno_core::error::ModuleLoaderError;
use deno_core::futures::FutureExt;
use deno_core::{
resolve_import, ModuleCodeBytes, ModuleLoadResponse, ModuleLoader, ModuleSource,
ModuleSourceCode, ModuleSpecifier, ModuleType, RequestedModuleType, ResolutionKind,
};
use deno_error::JsErrorBox;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use deno_core::anyhow::Result;
pub struct ZinniaModuleLoader {
module_root: Option<PathBuf>,
code_cache: Rc<RefCell<HashMap<String, String>>>,
source_maps: Rc<RefCell<HashMap<String, Vec<u8>>>>,
}
impl ZinniaModuleLoader {
pub fn build(module_root: Option<PathBuf>) -> Result<Self> {
let module_root = match module_root {
None => None,
Some(r) => Some(r.canonicalize()?),
};
Ok(Self {
module_root,
code_cache: Rc::new(RefCell::new(HashMap::new())),
source_maps: Rc::new(RefCell::new(HashMap::new())),
})
}
}
pub fn get_module_root(main_js_module: &ModuleSpecifier) -> Result<PathBuf> {
Ok(main_js_module
.to_file_path()
.map_err(|_| anyhow!("Invalid main module specifier: not a local path."))?
.parent()
.ok_or_else(|| anyhow!("Invalid main module specifier: it has no parent directory!"))?
.canonicalize()?)
}
impl ModuleLoader for ZinniaModuleLoader {
fn resolve(
&self,
specifier: &str,
referrer: &str,
_kind: ResolutionKind,
) -> Result<ModuleSpecifier, ModuleLoaderError> {
if specifier == "zinnia:test" {
return Ok(ModuleSpecifier::parse("ext:zinnia_runtime/test.js").unwrap());
} else if specifier == "zinnia:assert" {
return Ok(
ModuleSpecifier::parse("ext:zinnia_runtime/vendored/asserts.bundle.js").unwrap(),
);
}
let resolved = resolve_import(specifier, referrer)?;
Ok(resolved)
}
fn load(
&self,
module_specifier: &ModuleSpecifier,
maybe_referrer: Option<&ModuleSpecifier>,
_is_dyn_import: bool,
requested_module_type: RequestedModuleType,
) -> ModuleLoadResponse {
let module_specifier = module_specifier.clone();
let module_root = self.module_root.clone();
let maybe_referrer = maybe_referrer.cloned();
let code_cache = self.code_cache.clone();
let source_maps = self.source_maps.clone();
let module_load = async move {
let spec_str = module_specifier.as_str();
let details = || {
let mut msg = format!("\nModule URL: {spec_str}");
if let Some(referrer) = &maybe_referrer {
msg.push_str("\nImported from: ");
msg.push_str(referrer.as_str());
}
msg
};
if module_specifier.scheme() != "file" {
let msg = format!(
"Unsupported scheme: {}. Zinnia can import local modules only.{}",
module_specifier.scheme(),
details()
);
return Err(ModuleLoaderError::from(JsErrorBox::generic(msg)));
}
let module_path = module_specifier.to_file_path().map_err(|_| {
let msg = format!(
"Module specifier cannot be converted to a filepath.{}",
details()
);
ModuleLoaderError::from(JsErrorBox::generic(msg))
})?;
if let Some(canonical_root) = &module_root {
let canonical_module = module_path.canonicalize().map_err(|err| {
let msg = format!(
"Cannot canonicalize module path: {err}.\nModule file path: {}{}",
module_path.display(),
details()
);
ModuleLoaderError::from(JsErrorBox::generic(msg))
})?;
if !canonical_module.starts_with(canonical_root) {
let msg = format!(
"Cannot import files outside of the module root directory.\n\
Root directory (canonical): {}\n\
Module file path (canonical): {}\
{}",
canonical_root.display(),
canonical_module.display(),
details()
);
return Err(ModuleLoaderError::from(JsErrorBox::generic(msg)));
}
};
let media_type = MediaType::from_path(&module_path);
log::debug!("Loading module: {}", module_path.display());
log::debug!("Media type: {:?}", media_type);
let (module_type, should_transpile) = match MediaType::from_path(&module_path) {
MediaType::JavaScript => (ModuleType::JavaScript, false),
MediaType::TypeScript => (ModuleType::JavaScript, true),
MediaType::Json => (ModuleType::Json, false),
MediaType::Wasm => (ModuleType::Wasm, false),
_ => {
return Err(ModuleLoaderError::Unsupported {
specifier: module_specifier.into(),
maybe_referrer: maybe_referrer.map(|r| r.into()),
})
}
};
log::debug!(
"Module type: {} Should transpile: {}",
module_type,
should_transpile
);
if module_type == ModuleType::Json && requested_module_type != RequestedModuleType::Json
{
return Err(JsErrorBox::generic("Attempted to load JSON module without specifying \"type\": \"json\" attribute in the import statement.").into());
}
if module_type == ModuleType::Wasm {
let code = read_file(&module_path).await?;
let module = ModuleSource::new(
module_type,
ModuleSourceCode::Bytes(ModuleCodeBytes::Boxed(code.into_boxed_slice())),
&module_specifier,
None,
);
return Ok(module);
}
let code = read_file_to_string(&module_path).await?;
code_cache
.borrow_mut()
.insert(spec_str.to_string(), code.clone());
let code = if should_transpile {
let parsed = deno_ast::parse_module(ParseParams {
specifier: module_specifier.clone(),
text: code.into(),
media_type,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})
.map_err(JsErrorBox::from_err)?;
let res = parsed
.transpile(
&deno_ast::TranspileOptions {
imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Error,
verbatim_module_syntax: true,
..Default::default()
},
&Default::default(),
&deno_ast::EmitOptions {
source_map: deno_ast::SourceMapOption::Separate,
inline_sources: true,
..Default::default()
},
)
.map_err(JsErrorBox::from_err)?
.into_source();
if let Some(source_map) = res.source_map {
source_maps
.borrow_mut()
.insert(module_specifier.to_string(), source_map.into_bytes());
}
res.text
} else {
code
};
let module = ModuleSource::new(
module_type,
ModuleSourceCode::String(code.into()),
&module_specifier,
None,
);
Ok(module)
};
ModuleLoadResponse::Async(module_load.boxed_local())
}
fn get_source_map(&self, specifier: &str) -> Option<Cow<[u8]>> {
self.source_maps
.borrow()
.get(specifier)
.map(|v| v.clone().into())
}
fn get_source_mapped_source_line(&self, file_name: &str, line_number: usize) -> Option<String> {
log::debug!("get_source_mapped_source_line {file_name}:{line_number}");
let code_cache = self.code_cache.borrow();
let code = code_cache.get(file_name)?;
let lines: Vec<&str> = code.split('\n').collect();
if line_number >= lines.len() {
Some(format!(
"{} Couldn't format source line: Line {} is out of bounds (source may have changed at runtime)",
crate::colors::yellow("Warning"), line_number + 1,
))
} else {
Some(lines[line_number].to_string())
}
}
}
async fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>, ModuleLoaderError> {
let mut f = File::open(&path).await?;
let mut buffer = Vec::new();
f.read_to_end(&mut buffer).await?;
Ok(buffer)
}
async fn read_file_to_string(path: impl AsRef<Path>) -> Result<String, ModuleLoaderError> {
let buffer = read_file(path).await?;
Ok(String::from_utf8_lossy(&buffer).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use deno_core::{anyhow::Context, RequestedModuleType};
use pretty_assertions::assert_eq;
#[tokio::test]
async fn allows_import_of_files_inside_sandbox() {
let mut imported_file = get_js_dir();
imported_file.push("99_main.js");
let loader = ZinniaModuleLoader::build(Some(get_js_dir())).unwrap();
let response = loader.load(
&ModuleSpecifier::from_file_path(&imported_file).unwrap(),
None,
false,
RequestedModuleType::None,
);
let result = get_load_result(response)
.await
.with_context(|| format!("cannot import {}", imported_file.display()))
.unwrap();
assert_eq!(result.module_type, ModuleType::JavaScript);
}
#[tokio::test]
async fn rejects_import_of_files_outside_sandbox() {
let mut project_root = get_js_dir().parent().unwrap().to_path_buf();
project_root.push("tests");
project_root.push("js");
let mut imported_file = get_js_dir();
imported_file.push("99_main.js");
let loader = ZinniaModuleLoader::build(Some(project_root)).unwrap();
let response = loader.load(
&ModuleSpecifier::from_file_path(&imported_file).unwrap(),
None,
false,
RequestedModuleType::None,
);
let result = get_load_result(response).await;
match result {
Ok(_) => {
assert!(
result.is_err(),
"Expected import from '{}' to fail, it succeeded instead.",
imported_file.display()
);
}
Err(err) => {
let msg = format!("{err}");
assert!(
msg.contains("Cannot import files outside of the module root directory"),
"Expected import to fail with the sandboxing error, it failed with a different error instead:\n{}",
msg,
);
}
}
}
fn get_js_dir() -> PathBuf {
let mut base_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
base_dir.push("js");
base_dir
}
async fn get_load_result(
load_response: ModuleLoadResponse,
) -> Result<ModuleSource, ModuleLoaderError> {
match load_response {
ModuleLoadResponse::Sync(result) => result,
ModuleLoadResponse::Async(fut) => fut.await,
}
}
}