use std::path::Path;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use flate2::read::GzDecoder;
use tar::Archive;
use deno_npmrc::RegistryConfig;
use crate::environment::Environment;
use crate::utils::NpmSpecifier;
use crate::utils::PathSource;
use crate::utils::PluginKind;
use crate::utils::get_sha256_checksum;
use crate::utils::verify_sha256_checksum;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NpmRegistryResolution {
pub url: String,
pub auth_header: Option<String>,
}
pub struct NpmResolvedPlugin {
pub plugin_bytes: Vec<u8>,
pub plugin_kind: PluginKind,
pub local_path: PathSource,
pub pre_resolved_tarball: Option<PreResolvedProcessPluginTarball>,
pub resolved_path: String,
pub tarball_checksum: Option<String>,
}
pub struct PreResolvedProcessPluginTarball {
pub name: String,
pub version: String,
pub tarball_bytes: Vec<u8>,
pub executable_sub_path: String,
}
pub struct NpmLatestInfo {
pub version: String,
pub tarball_sha256: Option<String>,
}
pub struct FetchNpmLatestInfo<'a> {
pub specifier: &'a NpmSpecifier,
pub start_dir: Option<&'a Path>,
pub want_tarball_sha: bool,
}
pub async fn fetch_npm_latest_info(args: FetchNpmLatestInfo<'_>, environment: &impl Environment) -> Result<NpmLatestInfo> {
let FetchNpmLatestInfo {
specifier,
start_dir,
want_tarball_sha,
} = args;
let registry = resolve_registry_for_package(&specifier.name, start_dir, environment);
let (packument, packument_url) = fetch_packument(&specifier.name, ®istry, environment).await?;
let latest_version = latest_version_from_packument(&packument, &specifier.name)?;
let need_tarball_sha = want_tarball_sha || specifier.plugin_kind() != PluginKind::Wasm;
let tarball_sha256 = if need_tarball_sha {
let tarball_url_str = get_tarball_url_from_packument(&packument, &latest_version, &specifier.name)?;
let tarball_url = url::Url::parse(&tarball_url_str).with_context(|| format!("Failed to parse npm tarball URL: {}", tarball_url_str))?;
let tarball_auth = same_origin_auth(&packument_url, &tarball_url, registry.auth_header.as_deref());
let (_, tarball_file) = environment
.download_file_err_404(&tarball_url, tarball_auth)
.await
.with_context(|| format!("Failed to download npm tarball for {}@{}", specifier.name, latest_version))?;
Some(get_sha256_checksum(&tarball_file.content))
} else {
None
};
Ok(NpmLatestInfo {
version: latest_version,
tarball_sha256,
})
}
pub async fn resolve_npm_latest_version(name: &str, start_dir: Option<&Path>, environment: &impl Environment) -> Result<String> {
let registry = resolve_registry_for_package(name, start_dir, environment);
let (packument, _) = fetch_packument(name, ®istry, environment).await?;
latest_version_from_packument(&packument, name)
}
pub fn read_npm_tarball_checksum(name: &str, version: &str, start_dir: Option<&Path>, environment: &impl Environment) -> Option<String> {
let registry = resolve_registry_for_package(name, start_dir, environment);
let registry_segment = registry_dir_segment(®istry.url);
Some(read_npm_tarball_meta(®istry_segment, name, version, environment)?.tarball_sha256)
}
pub fn detect_extracted_npm_plugin(name: &str, version: &str, start_dir: Option<&Path>, environment: &impl Environment) -> Option<(String, PluginKind)> {
let registry = resolve_registry_for_package(name, start_dir, environment);
let registry_segment = registry_dir_segment(®istry.url);
let extract_dir = get_npm_extract_dir(®istry_segment, name, version, environment);
if !environment.path_exists(&extract_dir) {
return None;
}
let path = detect_plugin_path_in_dir(&extract_dir, environment)?;
let kind = plugin_kind_from_path(&path);
Some((path, kind))
}
pub fn detect_npm_plugin_kind_in_node_modules(package_name: &str, start_dir: &Path, environment: &impl Environment) -> Option<PluginKind> {
let package_dir = find_package_in_node_modules(package_name, start_dir, environment)?;
if environment.path_exists(package_dir.join("plugin.wasm")) {
Some(PluginKind::Wasm)
} else if environment.path_exists(package_dir.join("plugin.json")) {
Some(PluginKind::Process)
} else {
None
}
}
async fn fetch_packument(name: &str, registry: &NpmRegistryResolution, environment: &impl Environment) -> Result<(serde_json::Value, url::Url)> {
let packument_url_str = get_packument_url(®istry.url, name);
let packument_url = url::Url::parse(&packument_url_str).with_context(|| format!("Failed to parse npm packument URL: {}", packument_url_str))?;
let (_, packument_file) = environment
.download_file_err_404(&packument_url, registry.auth_header.as_deref())
.await
.with_context(|| format!("Failed to fetch npm packument for {}", name))?;
let packument = serde_json::from_slice(&packument_file.content).with_context(|| format!("Failed to parse npm packument for {}", name))?;
Ok((packument, packument_url))
}
fn latest_version_from_packument(packument: &serde_json::Value, name: &str) -> Result<String> {
packument
.get("dist-tags")
.and_then(|d| d.get("latest"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("Missing dist-tags.latest for {}", name))
}
fn detect_plugin_path_in_dir(extract_dir: &Path, environment: &impl Environment) -> Option<String> {
if environment.path_exists(extract_dir.join("plugin.wasm")) {
Some("plugin.wasm".to_string())
} else if environment.path_exists(extract_dir.join("plugin.json")) {
Some("plugin.json".to_string())
} else {
None
}
}
fn plugin_kind_from_path(path: &str) -> PluginKind {
if path.rsplit_once('.').is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("json")) {
PluginKind::Process
} else {
PluginKind::Wasm
}
}
pub struct ResolveNpmRegistryOptions<'a> {
pub specifier: &'a NpmSpecifier,
pub checksum: Option<&'a str>,
pub detect_path: bool,
pub establish_checksum: bool,
pub registry: &'a NpmRegistryResolution,
pub config_dir: Option<&'a Path>,
}
pub async fn resolve_npm_from_registry(options: ResolveNpmRegistryOptions<'_>, environment: &impl Environment) -> Result<NpmResolvedPlugin> {
let ResolveNpmRegistryOptions {
specifier,
checksum,
detect_path,
establish_checksum,
registry,
config_dir,
} = options;
let version = specifier
.version
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Cannot resolve npm plugin without a version from the registry"))?;
let registry_segment = registry_dir_segment(®istry.url);
let packument_url_str = get_packument_url(®istry.url, &specifier.name);
let packument_url = url::Url::parse(&packument_url_str).with_context(|| format!("Failed to parse npm packument URL: {}", packument_url_str))?;
log_debug!(environment, "Fetching npm packument: {}", packument_url);
let (_, packument_file) = environment
.download_file_err_404(&packument_url, registry.auth_header.as_deref())
.await
.with_context(|| format!("Failed to fetch npm packument for {}", specifier.name))?;
let packument: serde_json::Value =
serde_json::from_slice(&packument_file.content).with_context(|| format!("Failed to parse npm packument for {}", specifier.name))?;
let tarball_url_str = get_tarball_url_from_packument(&packument, version, &specifier.name)?;
let tarball_url = url::Url::parse(&tarball_url_str).with_context(|| format!("Failed to parse npm tarball URL: {}", tarball_url_str))?;
log_debug!(environment, "Downloading npm tarball: {}", tarball_url);
let tarball_auth = same_origin_auth(&packument_url, &tarball_url, registry.auth_header.as_deref());
let (_, tarball_file) = environment
.download_file_err_404(&tarball_url, tarball_auth)
.await
.with_context(|| format!("Failed to download npm tarball for {}@{}", specifier.name, version))?;
let tarball_bytes = tarball_file.content;
let tarball_sha256 = get_sha256_checksum(&tarball_bytes);
if !establish_checksum {
if let Some(checksum) = checksum {
if tarball_sha256 != checksum {
bail!(
"Invalid checksum for npm package {}. Check the plugin's release notes for the expected checksum.\n\nActual: {}\nExpected: {}",
specifier.display(),
tarball_sha256,
checksum,
);
}
} else if specifier.plugin_kind() != PluginKind::Wasm {
bail!(
concat!(
"The npm plugin must have a checksum specified for security reasons ",
"since it is not a Wasm plugin. Check the plugin's release notes for what ",
"the checksum is or if you trust the source, you may specify: {}@{}"
),
specifier.display(),
tarball_sha256,
);
}
}
let extract_dir = get_npm_extract_dir(®istry_segment, &specifier.name, version, environment);
let requested_path = specifier.path.clone();
let specifier_clone = specifier.clone();
let environment_clone = environment.clone();
let (plugin_bytes, local_path, resolved_path) = dprint_core::async_runtime::spawn_blocking(move || -> Result<_> {
let environment = environment_clone;
extract_tarball_to_dir(&tarball_bytes, &extract_dir, &environment)?;
let resolved_path = if detect_path {
detect_plugin_path_in_dir(&extract_dir, &environment)
.ok_or_else(|| anyhow::anyhow!("Could not find a plugin.wasm or plugin.json in npm package {}", specifier_clone.name))?
} else {
requested_path
};
let plugin_file_path = extract_dir.join(&resolved_path);
if !environment.path_exists(&plugin_file_path) {
bail!(missing_plugin_file_message(
&specifier_clone,
&format!("npm package {}", specifier_clone.name),
&extract_dir,
&environment,
));
}
let plugin_bytes = environment
.read_file_bytes(&plugin_file_path)
.with_context(|| format!("Failed to read {}", plugin_file_path.display()))?;
let canonical = environment.canonicalize(&plugin_file_path)?;
Ok((plugin_bytes, PathSource::new_local(canonical), resolved_path))
})
.await??;
let plugin_kind = plugin_kind_from_path(&resolved_path);
let pre_resolved_tarball = if plugin_kind == PluginKind::Process {
try_resolve_process_plugin_per_platform_tarball(&plugin_bytes, config_dir, environment).await?
} else {
None
};
write_npm_tarball_meta(®istry_segment, &specifier.name, version, &tarball_sha256, environment);
Ok(NpmResolvedPlugin {
plugin_bytes,
plugin_kind,
local_path,
pre_resolved_tarball,
resolved_path,
tarball_checksum: Some(tarball_sha256),
})
}
pub async fn resolve_npm_from_node_modules(specifier: &NpmSpecifier, config_dir: &Path, environment: &impl Environment) -> Result<NpmResolvedPlugin> {
let package_dir = match find_package_in_node_modules(&specifier.name, config_dir, environment) {
Some(dir) => dir,
None => bail!(node_modules_missing_message(specifier, Some(config_dir), environment).await),
};
let plugin_path = package_dir.join(&specifier.path);
if !environment.path_exists(&plugin_path) {
bail!(missing_plugin_file_message(
specifier,
&package_dir.display().to_string(),
&package_dir,
environment,
));
}
let canonical = environment.canonicalize(&plugin_path)?;
let local_path = PathSource::new_local(canonical.clone());
let plugin_bytes = environment
.read_file_bytes(canonical.as_ref())
.with_context(|| format!("Failed to read {}", canonical.display()))?;
let pre_resolved_tarball = if specifier.plugin_kind() == PluginKind::Process {
try_resolve_process_plugin_per_platform_tarball(&plugin_bytes, Some(config_dir), environment).await?
} else {
None
};
Ok(NpmResolvedPlugin {
plugin_bytes,
plugin_kind: specifier.plugin_kind(),
local_path,
pre_resolved_tarball,
resolved_path: specifier.path.clone(),
tarball_checksum: None,
})
}
pub fn find_npm_plugin_local_path(specifier: &NpmSpecifier, config_dir: &Path, environment: &impl Environment) -> Result<PathSource> {
let package_dir = find_package_in_node_modules(&specifier.name, config_dir, environment).ok_or_else(|| {
anyhow::anyhow!(
"Could not find {} in node_modules. Make sure the package is installed (npm install {}).",
specifier.name,
specifier.name,
)
})?;
let plugin_path = package_dir.join(&specifier.path);
if !environment.path_exists(&plugin_path) {
bail!(missing_plugin_file_message(
specifier,
&package_dir.display().to_string(),
&package_dir,
environment,
));
}
let canonical = environment.canonicalize(&plugin_path)?;
Ok(PathSource::new_local(canonical))
}
fn missing_plugin_file_message(specifier: &NpmSpecifier, package_display: &str, package_dir: &Path, environment: &impl Environment) -> String {
if let Some(alternate) = alternate_plugin_filename(&specifier.path, package_dir, environment) {
let suggestion = npm_specifier_with_path(specifier, alternate);
return format!(
"Could not find {} in {}. The package contains {} instead — reference it as `{}`.",
specifier.path, package_display, alternate, suggestion,
);
}
format!("Could not find {} in {}. Is the package a dprint plugin?", specifier.path, package_display)
}
fn alternate_plugin_filename(requested: &str, package_dir: &Path, environment: &impl Environment) -> Option<&'static str> {
let candidate = if requested.eq_ignore_ascii_case("plugin.wasm") {
"plugin.json"
} else if requested.eq_ignore_ascii_case("plugin.json") {
"plugin.wasm"
} else {
return None;
};
if environment.path_exists(package_dir.join(candidate)) {
Some(candidate)
} else {
None
}
}
fn npm_specifier_with_path(specifier: &NpmSpecifier, path: &str) -> String {
match &specifier.version {
Some(version) => format!("npm:{}@{}/{}", specifier.name, version, path),
None => format!("npm:{}/{}", specifier.name, path),
}
}
async fn try_resolve_process_plugin_per_platform_tarball(
plugin_json_bytes: &[u8],
config_dir: Option<&Path>,
environment: &impl Environment,
) -> Result<Option<PreResolvedProcessPluginTarball>> {
use crate::plugins::implementations::get_process_plugin_os_path;
use crate::plugins::implementations::parse_process_plugin_file;
let plugin_file = parse_process_plugin_file(plugin_json_bytes).context("Failed to parse process plugin manifest (plugin.json)")?;
let os_path = get_process_plugin_os_path(&plugin_file, environment)?;
if !os_path.reference.starts_with("npm:") {
bail_if_disallowed_reference(&plugin_file.name, &os_path.reference)?;
return Ok(None);
}
let parsed = crate::utils::parse_npm_specifier(&os_path.reference)?;
let version = parsed
.specifier
.version
.as_deref()
.ok_or_else(|| anyhow::anyhow!("npm reference in plugin '{}' must include a version: {}", plugin_file.name, os_path.reference,))?;
let registry = resolve_registry_for_package(&parsed.specifier.name, config_dir, environment);
let tarball_bytes = fetch_and_verify_npm_tarball(&parsed.specifier.name, version, &os_path.checksum, ®istry, environment)
.await
.with_context(|| format!("Resolving npm dependency for process plugin '{}'", plugin_file.name))?;
Ok(Some(PreResolvedProcessPluginTarball {
name: plugin_file.name,
version: plugin_file.version,
tarball_bytes,
executable_sub_path: parsed.specifier.path,
}))
}
async fn fetch_and_verify_npm_tarball(
name: &str,
version: &str,
expected_checksum: &str,
registry: &NpmRegistryResolution,
environment: &impl Environment,
) -> Result<Vec<u8>> {
let packument_url_str = get_packument_url(®istry.url, name);
let packument_url = url::Url::parse(&packument_url_str).with_context(|| format!("Failed to parse npm packument URL: {}", packument_url_str))?;
let (_, packument_file) = environment
.download_file_err_404(&packument_url, registry.auth_header.as_deref())
.await
.with_context(|| format!("Failed to fetch npm packument for {}", name))?;
let packument: serde_json::Value = serde_json::from_slice(&packument_file.content).with_context(|| format!("Failed to parse npm packument for {}", name))?;
let tarball_url_str = get_tarball_url_from_packument(&packument, version, name)?;
let tarball_url = url::Url::parse(&tarball_url_str).with_context(|| format!("Failed to parse npm tarball URL: {}", tarball_url_str))?;
let tarball_auth = same_origin_auth(&packument_url, &tarball_url, registry.auth_header.as_deref());
let (_, tarball_file) = environment
.download_file_err_404(&tarball_url, tarball_auth)
.await
.with_context(|| format!("Failed to download npm tarball for {}@{}", name, version))?;
let tarball_bytes = tarball_file.content;
if let Err(err) = verify_sha256_checksum(&tarball_bytes, expected_checksum) {
bail!(
"Invalid checksum for npm package {}@{}. The tarball's contents don't match the expected SHA-256.\n\n{:#}",
name,
version,
err,
);
}
Ok(tarball_bytes)
}
fn bail_if_disallowed_reference(plugin_name: &str, reference: &str) -> Result<()> {
if is_network_reference(reference) {
bail!(
concat!(
"Process plugin '{}' was installed via npm but its plugin.json references the platform ",
"binary over the network ({}). Network references aren't allowed for npm-installed plugins; ",
"the plugin author needs to ship the binary inside the npm package or as a separate npm package.",
),
plugin_name,
reference,
);
}
if escapes_package_dir(reference) {
bail!(
concat!(
"Process plugin '{}' was installed via npm but its plugin.json references the platform ",
"binary by a path outside the package ({}). npm-installed plugins may only reference a binary ",
"shipped inside the package (a relative path) or another npm package (an npm: specifier); ",
"the plugin author needs to fix the reference.",
),
plugin_name,
reference,
);
}
Ok(())
}
fn is_network_reference(reference: &str) -> bool {
url::Url::parse(reference)
.ok()
.filter(|u| !u.cannot_be_a_base())
.map(|u| matches!(u.scheme(), "http" | "https"))
.unwrap_or(false)
}
fn escapes_package_dir(reference: &str) -> bool {
if reference == "~" || reference.starts_with("~/") {
return true;
}
if is_absolute_reference(reference) {
return true;
}
if reference.split(['/', '\\']).any(|segment| segment == "..") {
return true;
}
url::Url::parse(reference)
.ok()
.filter(|u| !u.cannot_be_a_base())
.map(|u| u.scheme() == "file")
.unwrap_or(false)
}
fn is_absolute_reference(reference: &str) -> bool {
let bytes = reference.as_bytes();
if matches!(bytes.first(), Some(b'/' | b'\\')) {
return true;
}
let chars: Vec<char> = reference.chars().take(3).collect();
matches!(chars.first(), Some(c) if c.is_ascii_alphabetic()) && matches!(chars.get(1), Some(':')) && matches!(chars.get(2), Some('/' | '\\'))
}
pub(super) fn get_npm_extract_dir(registry_segment: &str, package_name: &str, version: &str, environment: &impl Environment) -> PathBuf {
let dir_name = format!("{}@{}", package_name.replace('/', "__"), version);
environment.get_cache_dir().join("npm").join(registry_segment).join(dir_name)
}
struct NpmTarballMeta {
tarball_sha256: String,
}
fn npm_tarball_meta_path(registry_segment: &str, package_name: &str, version: &str, environment: &impl Environment) -> PathBuf {
let file_name = format!("{}@{}.meta.json", package_name.replace('/', "__"), version);
environment.get_cache_dir().join("npm").join(registry_segment).join(file_name)
}
fn read_npm_tarball_meta(registry_segment: &str, package_name: &str, version: &str, environment: &impl Environment) -> Option<NpmTarballMeta> {
let path = npm_tarball_meta_path(registry_segment, package_name, version, environment);
let text = environment.read_file(&path).ok()?;
let value: serde_json::Value = serde_json::from_str(&text).ok()?;
let tarball_sha256 = value.get("tarballChecksum")?.as_str()?.to_string();
Some(NpmTarballMeta { tarball_sha256 })
}
fn write_npm_tarball_meta(registry_segment: &str, package_name: &str, version: &str, tarball_sha256: &str, environment: &impl Environment) {
let json = serde_json::json!({ "tarballChecksum": tarball_sha256 });
let path = npm_tarball_meta_path(registry_segment, package_name, version, environment);
let _ = environment.write_file(&path, &json.to_string());
}
pub(super) fn registry_dir_segment(registry_url: &str) -> String {
let fallback = || format!("unknown_{:016x}", crate::utils::get_bytes_hash(registry_url.as_bytes()));
let Ok(url) = url::Url::parse(registry_url) else {
return fallback();
};
let Some(host) = url.host_str() else {
return fallback();
};
match url.port() {
Some(port) => format!("{host}_{port}"),
None => host.to_string(),
}
}
fn extract_tarball_to_dir(tarball_bytes: &[u8], dest_dir: &Path, environment: &impl Environment) -> Result<()> {
use crate::utils::fs::get_atomic_path;
if environment.path_exists(dest_dir) {
return Ok(());
}
let temp_dir = get_atomic_path(environment, dest_dir);
environment.mk_dir_all(&temp_dir)?;
if let Err(err) = extract_tarball_to_dir_inner(tarball_bytes, &temp_dir, environment) {
environment.try_remove_dir_all(&temp_dir);
return Err(err);
}
match environment.rename(&temp_dir, dest_dir) {
Ok(()) => Ok(()),
Err(err) => {
if environment.path_exists(dest_dir) {
environment.try_remove_dir_all(&temp_dir);
Ok(())
} else {
environment.try_remove_dir_all(&temp_dir);
Err(err.into())
}
}
}
}
pub(in crate::plugins) fn extract_tarball_replacing(tarball_bytes: &[u8], dest_dir: &Path, environment: &impl Environment) -> Result<()> {
use crate::utils::fs::get_atomic_path;
let temp_dir = get_atomic_path(environment, dest_dir);
environment.mk_dir_all(&temp_dir)?;
if let Err(err) = extract_tarball_to_dir_inner(tarball_bytes, &temp_dir, environment) {
environment.try_remove_dir_all(&temp_dir);
return Err(err);
}
if let Err(err) = environment.remove_dir_all(dest_dir) {
environment.try_remove_dir_all(&temp_dir);
return Err(err.into());
}
if let Err(err) = environment.rename(&temp_dir, dest_dir) {
environment.try_remove_dir_all(&temp_dir);
return Err(err.into());
}
Ok(())
}
fn extract_tarball_to_dir_inner(tarball_bytes: &[u8], output_dir: &Path, environment: &impl Environment) -> Result<()> {
let decoder = GzDecoder::new(tarball_bytes);
let mut archive = Archive::new(decoder);
let mut wrapper: Option<std::ffi::OsString> = None;
let mut files_written: usize = 0;
for entry in archive.entries().context("Failed to read npm tarball entries")? {
let mut entry = entry.context("Failed to read npm tarball entry")?;
let entry_type = entry.header().entry_type();
match entry_type {
tar::EntryType::Regular | tar::EntryType::Directory => {}
tar::EntryType::Symlink | tar::EntryType::Link => {
continue;
}
tar::EntryType::XGlobalHeader => continue,
_ => continue,
}
let path = entry.path().context("Failed to get entry path")?.to_path_buf();
let mut components = path.components().peekable();
while let Some(std::path::Component::CurDir) = components.peek() {
components.next();
}
let Some(first) = components.next() else {
continue;
};
let std::path::Component::Normal(first_name) = first else {
bail!(
"Refusing to extract npm tarball entry with non-relative top-level component: {}",
path.display(),
);
};
let first_os = first_name.to_os_string();
match &wrapper {
None => wrapper = Some(first_os),
Some(existing) if existing == &first_os => {}
Some(existing) => {
bail!(
"Inconsistent npm tarball: expected all entries under '{}/' but found '{}'",
existing.to_string_lossy(),
path.display(),
);
}
}
let relative: PathBuf = components.collect();
if relative.as_os_str().is_empty() {
continue;
}
let dest_path = output_dir.join(&relative);
let normalized = normalize_path(&dest_path);
if !normalized.starts_with(output_dir) {
bail!("Refusing to extract tarball entry outside output directory: {}", path.display());
}
if entry_type == tar::EntryType::Directory {
environment.mk_dir_all(&dest_path)?;
continue;
}
if let Some(parent) = dest_path.parent() {
environment.mk_dir_all(parent)?;
}
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut bytes)?;
environment.write_file_bytes(&dest_path, &bytes)?;
files_written += 1;
#[cfg(unix)]
if let Ok(mode) = entry.header().mode()
&& mode != 0o644
{
use sys_traits::FsSetPermissions;
environment
.fs_set_permissions(&dest_path, mode)
.with_context(|| format!("Failed to set permissions on {}", dest_path.display()))?;
}
}
if files_written == 0 {
bail!("npm tarball contained no extractable files (expected at least one file under a wrapper directory)");
}
Ok(())
}
fn normalize_path(path: &Path) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::CurDir => {}
other => result.push(other),
}
}
result
}
pub fn resolve_registry_for_package(package_name: &str, start_dir: Option<&Path>, environment: &impl Environment) -> NpmRegistryResolution {
if let Some(registry) = environment.env_var("NPM_CONFIG_REGISTRY") {
let registry = registry.to_string_lossy().to_string();
return NpmRegistryResolution {
url: registry.trim_end_matches('/').to_string(),
auth_header: None,
};
}
if let Some(start) = start_dir {
for dir in start.ancestors() {
if let Some(info) = resolve_registry_from_npmrc(package_name, &dir.join(".npmrc"), environment) {
return info;
}
}
}
if let Some(home_dir) = environment.get_home_dir()
&& let Some(info) = resolve_registry_from_npmrc(package_name, &home_dir.join(".npmrc"), environment)
{
return info;
}
NpmRegistryResolution {
url: deno_npmrc::NPM_DEFAULT_REGISTRY.to_string(),
auth_header: None,
}
}
fn resolve_registry_from_npmrc(package_name: &str, npmrc_path: &Path, environment: &impl Environment) -> Option<NpmRegistryResolution> {
let text = environment.read_file(npmrc_path).ok()?;
let npmrc = deno_npmrc::NpmRc::parse(environment, &text).ok()?;
let scope = scope_of(package_name);
let has_default = npmrc.registry.is_some();
let has_scope = scope.is_some_and(|s| npmrc.scope_registries.contains_key(s));
if !has_default && !has_scope {
return None;
}
let default_url = url::Url::parse(deno_npmrc::NPM_DEFAULT_REGISTRY).unwrap();
let registry_url = deno_npmrc::NpmRegistryUrl {
url: default_url,
from_env: false,
};
let resolved = npmrc.as_resolved(®istry_url).ok()?;
let url = resolved.get_registry_url(package_name).as_str().trim_end_matches('/').to_string();
let auth_header = compute_auth_header(resolved.get_registry_config(package_name).as_ref(), environment);
Some(NpmRegistryResolution { url, auth_header })
}
fn scope_of(package_name: &str) -> Option<&str> {
package_name.strip_prefix('@')?.split_once('/').map(|(scope, _)| scope)
}
fn compute_auth_header(config: &RegistryConfig, environment: &impl Environment) -> Option<String> {
use base64::Engine;
if let Some(token) = &config.auth_token {
return Some(format!("Bearer {}", token));
}
if let Some(auth) = &config.auth {
return Some(format!("Basic {}", auth));
}
if let (Some(username), Some(password_b64)) = (&config.username, &config.password) {
let password_bytes = match base64::engine::general_purpose::STANDARD.decode(password_b64.as_bytes()) {
Ok(bytes) => bytes,
Err(err) => {
log_warn!(
environment,
"Ignoring .npmrc _password for user '{}': not valid base64 ({}). Request will be sent unauthenticated.",
username,
err,
);
return None;
}
};
let password = match String::from_utf8(password_bytes) {
Ok(s) => s,
Err(_) => {
log_warn!(
environment,
"Ignoring .npmrc _password for user '{}': decoded bytes are not valid UTF-8. Request will be sent unauthenticated.",
username,
);
return None;
}
};
let credentials = format!("{}:{}", username, password);
return Some(format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes())));
}
if config.username.is_some() || config.password.is_some() {
log_warn!(
environment,
"Ignoring .npmrc credentials: 'username' and '_password' must both be set (saw only one). Request will be sent unauthenticated.",
);
}
None
}
fn same_origin_auth<'a>(registry: &url::Url, other: &url::Url, auth: Option<&'a str>) -> Option<&'a str> {
let same =
registry.scheme() == other.scheme() && registry.host_str() == other.host_str() && registry.port_or_known_default() == other.port_or_known_default();
if same { auth } else { None }
}
fn get_packument_url(registry_url: &str, package_name: &str) -> String {
format!("{}/{}", registry_url, package_name)
}
fn get_tarball_url_from_packument(packument: &serde_json::Value, version: &str, package_name: &str) -> Result<String> {
let versions = packument
.get("versions")
.and_then(|v| v.as_object())
.ok_or_else(|| anyhow::anyhow!("Invalid packument for {}: missing 'versions' object", package_name))?;
let version_obj = versions
.get(version)
.and_then(|v| v.as_object())
.ok_or_else(|| anyhow::anyhow!("Version {} not found for package {}", version, package_name))?;
let tarball_url = version_obj
.get("dist")
.and_then(|d| d.get("tarball"))
.and_then(|t| t.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing tarball URL for {}@{}", package_name, version))?;
Ok(tarball_url.to_string())
}
fn find_package_in_node_modules(package_name: &str, start_dir: &Path, environment: &impl Environment) -> Option<std::path::PathBuf> {
for dir in start_dir.ancestors() {
let candidate = dir.join("node_modules").join(package_name);
if environment.path_exists(&candidate) {
return Some(candidate);
}
}
None
}
async fn node_modules_missing_message(specifier: &NpmSpecifier, start_dir: Option<&Path>, environment: &impl Environment) -> String {
match fetch_npm_latest_version(&specifier.name, start_dir, environment).await {
Some(version) => {
let suggestion = if specifier.path == "plugin.wasm" {
format!("npm:{}@{}", specifier.name, version)
} else {
format!("npm:{}@{}/{}", specifier.name, version, specifier.path)
};
format!(
concat!(
"Could not find {} in node_modules.\n",
"\n",
"1. Make sure the package is installed (ex. npm install {})\n",
"2. OR specify a version (ex. {})",
),
specifier.name, specifier.name, suggestion,
)
}
None => format!(
"Could not find {} in node_modules. Make sure the package is installed (npm install {}).",
specifier.name, specifier.name,
),
}
}
async fn fetch_npm_latest_version(package_name: &str, start_dir: Option<&Path>, environment: &impl Environment) -> Option<String> {
let registry = resolve_registry_for_package(package_name, start_dir, environment);
let packument_url = url::Url::parse(&get_packument_url(®istry.url, package_name)).ok()?;
let (_, packument_file) = environment.download_file_err_404(&packument_url, registry.auth_header.as_deref()).await.ok()?;
let packument: serde_json::Value = serde_json::from_slice(&packument_file.content).ok()?;
packument
.get("dist-tags")
.and_then(|d| d.get("latest"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bail_if_disallowed_reference_only_allows_in_package_relative_paths() {
for ok in ["bin.zip", "./bin.zip", "sub/bin", "sub/dir/foo.exe"] {
assert!(bail_if_disallowed_reference("p", ok).is_ok(), "expected {ok} to be allowed");
}
let network = ["http://example.com/bin.zip", "https://example.com/bin.zip"];
for r in network {
let err = bail_if_disallowed_reference("p", r).unwrap_err().to_string();
assert!(err.contains("Network references aren't allowed"), "got: {err}");
}
let outside = [
"file:///etc/passwd",
"/etc/passwd",
"\\\\server\\share\\bin",
"C:\\Windows\\bin.exe",
"C:/Windows/bin.exe",
"~/bin.zip",
"../escape/bin",
"sub/../../escape",
];
for r in outside {
let err = bail_if_disallowed_reference("p", r).unwrap_err().to_string();
assert!(err.contains("outside the package"), "expected {r} rejected, got: {err}");
}
}
#[test]
fn compute_auth_header_supports_auth_token_and_auth() {
use crate::environment::TestEnvironment;
let env = TestEnvironment::new();
let mut cfg = RegistryConfig::default();
assert_eq!(compute_auth_header(&cfg, &env), None);
cfg.auth_token = Some("tok".to_string());
assert_eq!(compute_auth_header(&cfg, &env), Some("Bearer tok".to_string()));
cfg.auth_token = None;
cfg.auth = Some("dXNlcjpwd2Q=".to_string());
assert_eq!(compute_auth_header(&cfg, &env), Some("Basic dXNlcjpwd2Q=".to_string()));
cfg.auth_token = Some("tok".to_string());
assert_eq!(compute_auth_header(&cfg, &env), Some("Bearer tok".to_string()));
}
#[test]
fn compute_auth_header_supports_username_and_password() {
use crate::environment::TestEnvironment;
use base64::Engine;
let env = TestEnvironment::new();
let password_b64 = base64::engine::general_purpose::STANDARD.encode(b"pwd");
let cfg = RegistryConfig {
username: Some("user".to_string()),
password: Some(password_b64),
..Default::default()
};
assert_eq!(compute_auth_header(&cfg, &env), Some("Basic dXNlcjpwd2Q=".to_string()));
}
#[test]
fn compute_auth_header_username_only_warns_and_returns_none() {
use crate::environment::TestEnvironment;
let env = TestEnvironment::new();
let cfg = RegistryConfig {
username: Some("user".to_string()),
..Default::default()
};
assert_eq!(compute_auth_header(&cfg, &env), None);
let stderr = env.take_stderr_messages();
assert!(
stderr.iter().any(|m| m.contains("'username' and '_password' must both be set")),
"expected partial-config warning, got: {stderr:?}"
);
}
#[test]
fn compute_auth_header_invalid_base64_password_warns_and_returns_none() {
use crate::environment::TestEnvironment;
let env = TestEnvironment::new();
let cfg = RegistryConfig {
username: Some("user".to_string()),
password: Some("!!!not base64!!!".to_string()),
..Default::default()
};
assert_eq!(compute_auth_header(&cfg, &env), None);
let stderr = env.take_stderr_messages();
assert!(
stderr.iter().any(|m| m.contains("not valid base64") && m.contains("user")),
"expected base64 warning, got: {stderr:?}"
);
}
#[test]
fn compute_auth_header_non_utf8_password_warns_and_returns_none() {
use crate::environment::TestEnvironment;
use base64::Engine;
let env = TestEnvironment::new();
let password_b64 = base64::engine::general_purpose::STANDARD.encode([0xff, 0xfe, 0xfd]);
let cfg = RegistryConfig {
username: Some("user".to_string()),
password: Some(password_b64),
..Default::default()
};
assert_eq!(compute_auth_header(&cfg, &env), None);
let stderr = env.take_stderr_messages();
assert!(stderr.iter().any(|m| m.contains("not valid UTF-8")), "expected utf-8 warning, got: {stderr:?}");
}
#[tokio::test]
async fn resolve_registry_walks_past_unrelated_npmrc() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.mk_dir_all("/repo").unwrap();
environment.write_file("/repo/.npmrc", "@other:registry=https://other.example.com").unwrap();
environment.write_file("/.npmrc", "@dprint:registry=https://dprint.example.com").unwrap();
let info = resolve_registry_for_package("@dprint/typescript", Some(std::path::Path::new("/repo")), &environment);
assert_eq!(info.url, "https://dprint.example.com");
}
#[tokio::test]
async fn resolve_registry_picks_up_auth_token() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.mk_dir_all("/repo").unwrap();
environment
.write_file(
"/repo/.npmrc",
"@dprint:registry=https://dprint.example.com\n//dprint.example.com/:_authToken=MYTOKEN",
)
.unwrap();
let info = resolve_registry_for_package("@dprint/typescript", Some(std::path::Path::new("/repo")), &environment);
assert_eq!(info.url, "https://dprint.example.com");
assert_eq!(info.auth_header.as_deref(), Some("Bearer MYTOKEN"));
}
#[tokio::test]
async fn fetch_npm_latest_info_sends_auth_header() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.mk_dir_all("/repo").unwrap();
environment
.write_file(
"/repo/.npmrc",
"@dprint:registry=https://dprint.example.com\n//dprint.example.com/:_authToken=MYTOKEN",
)
.unwrap();
let packument = serde_json::json!({
"dist-tags": { "latest": "1.2.3" },
"versions": { "1.2.3": { "dist": { "tarball": "https://dprint.example.com/@dprint/foo/-/foo-1.2.3.tgz" } } }
});
environment.add_remote_file_bytes("https://dprint.example.com/@dprint/foo", packument.to_string().into_bytes());
let specifier = NpmSpecifier {
name: "@dprint/foo".to_string(),
version: Some("1.0.0".to_string()),
path: "plugin.wasm".to_string(),
};
let info = fetch_npm_latest_info(
FetchNpmLatestInfo {
specifier: &specifier,
start_dir: Some(std::path::Path::new("/repo")),
want_tarball_sha: false,
},
&environment,
)
.await
.unwrap();
assert_eq!(info.version, "1.2.3");
let seen = environment.take_remote_file_auth("https://dprint.example.com/@dprint/foo");
assert_eq!(seen.as_deref(), Some("Bearer MYTOKEN"));
}
#[test]
fn test_get_packument_url_scoped() {
assert_eq!(
get_packument_url("https://registry.npmjs.org", "@dprint/typescript"),
"https://registry.npmjs.org/@dprint/typescript"
);
}
#[test]
fn test_get_packument_url_unscoped() {
assert_eq!(
get_packument_url("https://registry.npmjs.org", "dprint-plugin-foo"),
"https://registry.npmjs.org/dprint-plugin-foo"
);
}
#[test]
fn test_get_tarball_url_from_packument() {
let packument = serde_json::json!({
"versions": {
"0.23.0": {
"dist": {
"tarball": "https://registry.npmjs.org/@dprint/typescript/-/typescript-0.23.0.tgz"
}
}
}
});
let result = get_tarball_url_from_packument(&packument, "0.23.0", "@dprint/typescript").unwrap();
assert_eq!(result, "https://registry.npmjs.org/@dprint/typescript/-/typescript-0.23.0.tgz");
}
#[test]
fn test_get_tarball_url_version_not_found() {
let packument = serde_json::json!({
"versions": {}
});
let result = get_tarball_url_from_packument(&packument, "0.23.0", "@dprint/typescript");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Version 0.23.0 not found"));
}
#[tokio::test]
async fn fetch_npm_latest_info_wasm_skips_tarball_download() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let packument = serde_json::json!({
"dist-tags": { "latest": "1.2.3" },
"versions": { "1.2.3": { "dist": { "tarball": "https://registry.npmjs.org/foo/-/foo-1.2.3.tgz" } } }
});
environment.add_remote_file_bytes("https://registry.npmjs.org/foo", packument.to_string().into_bytes());
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: Some("1.0.0".to_string()),
path: "plugin.wasm".to_string(),
};
let info = fetch_npm_latest_info(
FetchNpmLatestInfo {
specifier: &specifier,
start_dir: None,
want_tarball_sha: false,
},
&environment,
)
.await
.unwrap();
assert_eq!(info.version, "1.2.3");
assert!(info.tarball_sha256.is_none());
}
#[tokio::test]
async fn fetch_npm_latest_info_wasm_with_want_tarball_sha_fetches_tarball() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let packument = serde_json::json!({
"dist-tags": { "latest": "1.2.3" },
"versions": { "1.2.3": { "dist": { "tarball": "https://registry.npmjs.org/foo/-/foo-1.2.3.tgz" } } }
});
environment.add_remote_file_bytes("https://registry.npmjs.org/foo", packument.to_string().into_bytes());
let tarball_bytes = vec![1u8, 2, 3, 4];
let expected = get_sha256_checksum(&tarball_bytes);
environment.add_remote_file_bytes("https://registry.npmjs.org/foo/-/foo-1.2.3.tgz", tarball_bytes);
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: Some("1.0.0".to_string()),
path: "plugin.wasm".to_string(),
};
let info = fetch_npm_latest_info(
FetchNpmLatestInfo {
specifier: &specifier,
start_dir: None,
want_tarball_sha: true,
},
&environment,
)
.await
.unwrap();
assert_eq!(info.version, "1.2.3");
assert_eq!(info.tarball_sha256.as_deref(), Some(expected.as_str()));
}
#[tokio::test]
async fn fetch_npm_latest_info_process_downloads_tarball_for_checksum() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let packument = serde_json::json!({
"dist-tags": { "latest": "2.0.0" },
"versions": { "2.0.0": { "dist": { "tarball": "https://registry.npmjs.org/foo/-/foo-2.0.0.tgz" } } }
});
environment.add_remote_file_bytes("https://registry.npmjs.org/foo", packument.to_string().into_bytes());
let tarball_bytes = vec![0u8, 1, 2, 3, 4, 5];
let expected_checksum = get_sha256_checksum(&tarball_bytes);
environment.add_remote_file_bytes("https://registry.npmjs.org/foo/-/foo-2.0.0.tgz", tarball_bytes);
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: Some("1.0.0".to_string()),
path: "plugin.json".to_string(),
};
let info = fetch_npm_latest_info(
FetchNpmLatestInfo {
specifier: &specifier,
start_dir: None,
want_tarball_sha: false,
},
&environment,
)
.await
.unwrap();
assert_eq!(info.version, "2.0.0");
assert_eq!(info.tarball_sha256.as_deref(), Some(expected_checksum.as_str()));
}
#[test]
fn detect_plugin_path_in_dir_prefers_wasm_then_json() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.mk_dir_all("/pkg").unwrap();
assert_eq!(detect_plugin_path_in_dir(std::path::Path::new("/pkg"), &environment), None);
environment.write_file("/pkg/plugin.json", "{}").unwrap();
assert_eq!(
detect_plugin_path_in_dir(std::path::Path::new("/pkg"), &environment).as_deref(),
Some("plugin.json")
);
environment.write_file("/pkg/plugin.wasm", "\0asm").unwrap();
assert_eq!(
detect_plugin_path_in_dir(std::path::Path::new("/pkg"), &environment).as_deref(),
Some("plugin.wasm")
);
}
#[test]
fn plugin_kind_from_path_by_extension() {
assert_eq!(plugin_kind_from_path("plugin.wasm"), PluginKind::Wasm);
assert_eq!(plugin_kind_from_path("plugin.json"), PluginKind::Process);
assert_eq!(plugin_kind_from_path("sub/plugin.JSON"), PluginKind::Process);
assert_eq!(plugin_kind_from_path("foo"), PluginKind::Wasm);
}
#[tokio::test]
async fn resolve_npm_from_registry_add_mode_detects_path_checksums_and_writes_sidecar() {
use crate::environment::TestEnvironment;
use crate::test_helpers::create_test_npm_tarball;
let environment = TestEnvironment::new();
let packument = serde_json::json!({
"versions": { "1.0.0": { "dist": { "tarball": "https://registry.npmjs.org/foo/-/foo-1.0.0.tgz" } } }
});
environment.add_remote_file_bytes("https://registry.npmjs.org/foo", packument.to_string().into_bytes());
let tarball = create_test_npm_tarball(&[("package/plugin.wasm", b"\0asm")]);
let expected = get_sha256_checksum(&tarball);
environment.add_remote_file_bytes("https://registry.npmjs.org/foo/-/foo-1.0.0.tgz", tarball);
let registry = NpmRegistryResolution {
url: "https://registry.npmjs.org".to_string(),
auth_header: None,
};
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: Some("1.0.0".to_string()),
path: "plugin.wasm".to_string(),
};
let resolved = resolve_npm_from_registry(
ResolveNpmRegistryOptions {
specifier: &specifier,
checksum: None,
detect_path: true,
establish_checksum: true,
registry: ®istry,
config_dir: None,
},
&environment,
)
.await
.unwrap();
assert_eq!(resolved.resolved_path, "plugin.wasm");
assert_eq!(resolved.plugin_kind, PluginKind::Wasm);
assert_eq!(resolved.tarball_checksum.as_deref(), Some(expected.as_str()));
assert_eq!(
read_npm_tarball_checksum("foo", "1.0.0", None, &environment).as_deref(),
Some(expected.as_str())
);
assert_eq!(
detect_extracted_npm_plugin("foo", "1.0.0", None, &environment),
Some(("plugin.wasm".to_string(), PluginKind::Wasm))
);
}
#[test]
fn detect_npm_plugin_kind_in_node_modules_reads_installed_package() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.mk_dir_all("/repo/node_modules/proc").unwrap();
environment.mk_dir_all("/repo/node_modules/wasmy").unwrap();
environment.write_file("/repo/node_modules/proc/plugin.json", "{}").unwrap();
environment.write_file("/repo/node_modules/wasmy/plugin.wasm", "\0asm").unwrap();
assert_eq!(
detect_npm_plugin_kind_in_node_modules("proc", std::path::Path::new("/repo"), &environment),
Some(PluginKind::Process)
);
assert_eq!(
detect_npm_plugin_kind_in_node_modules("wasmy", std::path::Path::new("/repo"), &environment),
Some(PluginKind::Wasm)
);
assert_eq!(
detect_npm_plugin_kind_in_node_modules("missing", std::path::Path::new("/repo"), &environment),
None
);
}
#[test]
fn test_extract_tarball_to_dir() {
use crate::environment::RealEnvironment;
RealEnvironment::run_test_with_real_env(|env| {
Box::pin(async move {
let tarball = create_test_tarball(&[("package/plugin.wasm", b"wasm-bytes"), ("package/extra/data.bin", b"extra-data")]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("extracted");
extract_tarball_to_dir(&tarball, &dest, &env).unwrap();
assert_eq!(std::fs::read(dest.join("plugin.wasm")).unwrap(), b"wasm-bytes");
assert_eq!(std::fs::read(dest.join("extra").join("data.bin")).unwrap(), b"extra-data");
assert!(!dest.join("package").exists());
})
});
}
#[test]
fn extract_tarball_rejects_inconsistent_wrapper() {
use crate::environment::RealEnvironment;
RealEnvironment::run_test_with_real_env(|env| {
Box::pin(async move {
let tarball = create_test_tarball(&[("package/plugin.wasm", b"wasm-bytes"), ("other/extra.bin", b"stray")]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("extracted");
let err = extract_tarball_to_dir(&tarball, &dest, &env).unwrap_err();
assert!(err.to_string().contains("Inconsistent npm tarball"), "got: {}", err);
})
});
}
#[test]
fn extract_tarball_rejects_root_only_entries() {
use crate::environment::RealEnvironment;
RealEnvironment::run_test_with_real_env(|env| {
Box::pin(async move {
let tarball = create_test_tarball(&[("plugin.wasm", b"wasm-bytes")]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("extracted");
let err = extract_tarball_to_dir(&tarball, &dest, &env).unwrap_err();
assert!(err.to_string().contains("no extractable files"), "got: {}", err);
})
});
}
#[test]
fn extract_tarball_accepts_leading_curdir_components() {
use crate::environment::RealEnvironment;
use crate::test_helpers::create_test_npm_tarball_raw_paths;
RealEnvironment::run_test_with_real_env(|env| {
Box::pin(async move {
let tarball = create_test_npm_tarball_raw_paths(&[("./package/plugin.wasm", b"wasm-bytes"), ("./package/extra.bin", b"extra")]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("extracted");
extract_tarball_to_dir(&tarball, &dest, &env).unwrap();
assert_eq!(std::fs::read(dest.join("plugin.wasm")).unwrap(), b"wasm-bytes");
assert_eq!(std::fs::read(dest.join("extra.bin")).unwrap(), b"extra");
})
});
}
#[test]
fn extract_tarball_rejects_absolute_path_entries() {
use crate::environment::RealEnvironment;
use crate::test_helpers::create_test_npm_tarball_raw_paths;
RealEnvironment::run_test_with_real_env(|env| {
Box::pin(async move {
let tarball = create_test_npm_tarball_raw_paths(&[("/etc/passwd", b"pwned")]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("extracted");
let err = extract_tarball_to_dir(&tarball, &dest, &env).unwrap_err();
assert!(err.to_string().contains("non-relative top-level component"), "got: {}", err);
})
});
}
use crate::test_helpers::create_test_npm_tarball as create_test_tarball;
#[test]
fn registry_dir_segment_uses_host_for_normal_urls() {
assert_eq!(registry_dir_segment("https://registry.npmjs.org"), "registry.npmjs.org");
assert_eq!(registry_dir_segment("https://registry.npmjs.org/"), "registry.npmjs.org");
assert_eq!(registry_dir_segment("http://localhost:8080"), "localhost_8080");
}
#[test]
fn registry_dir_segment_unparseable_urls_get_distinct_hashed_segments() {
let a = registry_dir_segment("not a url at all");
let b = registry_dir_segment("also not a url");
assert!(a.starts_with("unknown_"), "got: {a}");
assert!(b.starts_with("unknown_"), "got: {b}");
assert_ne!(a, b);
assert_eq!(a, registry_dir_segment("not a url at all"));
}
#[test]
fn registry_dir_segment_hostless_urls_get_distinct_hashed_segments() {
let a = registry_dir_segment("file:///tmp/registry-a");
let b = registry_dir_segment("file:///tmp/registry-b");
assert!(a.starts_with("unknown_"), "got: {a}");
assert!(b.starts_with("unknown_"), "got: {b}");
assert_ne!(a, b);
}
#[test]
fn extract_tarball_is_idempotent_when_dest_dir_exists() {
use crate::environment::RealEnvironment;
RealEnvironment::run_test_with_real_env(|env| {
Box::pin(async move {
let tarball = create_test_tarball(&[("package/plugin.wasm", b"first-extract")]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("extracted");
extract_tarball_to_dir(&tarball, &dest, &env).unwrap();
assert_eq!(std::fs::read(dest.join("plugin.wasm")).unwrap(), b"first-extract");
let different = create_test_tarball(&[("package/plugin.wasm", b"second-extract")]);
extract_tarball_to_dir(&different, &dest, &env).unwrap();
assert_eq!(
std::fs::read(dest.join("plugin.wasm")).unwrap(),
b"first-extract",
"dest_dir should not be re-extracted"
);
let leftover: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name())
.filter(|name| name.to_string_lossy().contains(".tmp"))
.collect();
assert!(leftover.is_empty(), "expected no .tmp leftover, got {leftover:?}");
})
});
}
#[test]
fn extract_tarball_fast_paths_when_dest_dir_exists_from_a_prior_extract() {
use crate::environment::RealEnvironment;
RealEnvironment::run_test_with_real_env(|env| {
Box::pin(async move {
let tarball = create_test_tarball(&[("package/plugin.wasm", b"second-attempt")]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("extracted");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("plugin.wasm"), b"winner").unwrap();
extract_tarball_to_dir(&tarball, &dest, &env).unwrap();
assert_eq!(std::fs::read(dest.join("plugin.wasm")).unwrap(), b"winner");
let leftover: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name())
.filter(|name| name.to_string_lossy().contains(".tmp"))
.collect();
assert!(leftover.is_empty(), "expected no .tmp leftover, got {leftover:?}");
})
});
}
fn extract_tarball_skipping_existence_check<E: Environment>(tarball_bytes: &[u8], dest_dir: &Path, environment: &E) -> Result<()> {
use crate::utils::fs::get_atomic_path;
let temp_dir = get_atomic_path(environment, dest_dir);
environment.mk_dir_all(&temp_dir)?;
if let Err(err) = extract_tarball_to_dir_inner(tarball_bytes, &temp_dir, environment) {
let _ = environment.remove_dir_all(&temp_dir);
return Err(err);
}
match environment.rename(&temp_dir, dest_dir) {
Ok(()) => Ok(()),
Err(err) => {
if environment.path_exists(dest_dir) {
let _ = environment.remove_dir_all(&temp_dir);
Ok(())
} else {
let _ = environment.remove_dir_all(&temp_dir);
Err(err.into())
}
}
}
}
#[test]
fn extract_tarball_falls_back_when_rename_loses_to_a_concurrent_extract() {
use crate::environment::RealEnvironment;
RealEnvironment::run_test_with_real_env(|env| {
Box::pin(async move {
let tarball = create_test_tarball(&[("package/plugin.wasm", b"loser")]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("extracted");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(dest.join("plugin.wasm"), b"winner").unwrap();
extract_tarball_skipping_existence_check(&tarball, &dest, &env).unwrap();
assert_eq!(std::fs::read(dest.join("plugin.wasm")).unwrap(), b"winner");
let leftover: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name())
.filter(|name| name.to_string_lossy().contains(".tmp"))
.collect();
assert!(leftover.is_empty(), "expected no .tmp leftover, got {leftover:?}");
})
});
}
#[tokio::test]
async fn download_with_auth_keeps_header_on_same_origin_redirect() {
use crate::environment::TestEnvironment;
use crate::environment::UrlDownloader;
let environment = TestEnvironment::new();
let start = "https://registry.example.com/foo";
let redirected = "https://registry.example.com/foo/latest";
environment.add_remote_file_redirect(start, redirected);
environment.add_remote_file_bytes(redirected, b"ok".to_vec());
let url = url::Url::parse(start).unwrap();
let _ = environment.download_file_err_404(&url, Some("Bearer T")).await.unwrap();
assert_eq!(environment.take_remote_file_auth(start).as_deref(), Some("Bearer T"));
assert_eq!(environment.take_remote_file_auth(redirected).as_deref(), Some("Bearer T"));
}
#[tokio::test]
async fn download_with_auth_drops_header_on_cross_origin_redirect() {
use crate::environment::TestEnvironment;
use crate::environment::UrlDownloader;
let environment = TestEnvironment::new();
let start = "https://registry.example.com/foo";
let cdn = "https://cdn.example.net/foo.tgz";
environment.add_remote_file_redirect(start, cdn);
environment.add_remote_file_bytes(cdn, b"tarball".to_vec());
let url = url::Url::parse(start).unwrap();
let _ = environment.download_file_err_404(&url, Some("Bearer T")).await.unwrap();
assert_eq!(environment.take_remote_file_auth(start).as_deref(), Some("Bearer T"));
assert_eq!(environment.take_remote_file_auth(cdn), None);
}
#[tokio::test]
async fn resolve_npm_from_registry_sends_auth_on_packument_and_tarball() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let packument = serde_json::json!({
"versions": {
"1.0.0": { "dist": { "tarball": "https://private.example.com/foo/-/foo-1.0.0.tgz" } }
}
});
environment.add_remote_file_bytes("https://private.example.com/foo", packument.to_string().into_bytes());
let tarball = create_test_tarball(&[("package/plugin.wasm", b"wasm")]);
let tarball_checksum = get_sha256_checksum(&tarball);
environment.add_remote_file_bytes("https://private.example.com/foo/-/foo-1.0.0.tgz", tarball);
let registry = NpmRegistryResolution {
url: "https://private.example.com".to_string(),
auth_header: Some("Bearer SECRET".to_string()),
};
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: Some("1.0.0".to_string()),
path: "plugin.wasm".to_string(),
};
let _ = resolve_npm_from_registry(
ResolveNpmRegistryOptions {
specifier: &specifier,
checksum: Some(&tarball_checksum),
detect_path: false,
establish_checksum: false,
registry: ®istry,
config_dir: None,
},
&environment,
)
.await
.unwrap();
assert_eq!(
environment.take_remote_file_auth("https://private.example.com/foo").as_deref(),
Some("Bearer SECRET")
);
assert_eq!(
environment.take_remote_file_auth("https://private.example.com/foo/-/foo-1.0.0.tgz").as_deref(),
Some("Bearer SECRET")
);
}
#[tokio::test]
async fn resolve_npm_from_registry_drops_auth_on_cross_origin_tarball() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let packument = serde_json::json!({
"versions": {
"1.0.0": { "dist": { "tarball": "https://cdn.example.net/foo-1.0.0.tgz" } }
}
});
environment.add_remote_file_bytes("https://private.example.com/foo", packument.to_string().into_bytes());
let tarball = create_test_tarball(&[("package/plugin.wasm", b"wasm")]);
let tarball_checksum = get_sha256_checksum(&tarball);
environment.add_remote_file_bytes("https://cdn.example.net/foo-1.0.0.tgz", tarball);
let registry = NpmRegistryResolution {
url: "https://private.example.com".to_string(),
auth_header: Some("Bearer SECRET".to_string()),
};
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: Some("1.0.0".to_string()),
path: "plugin.wasm".to_string(),
};
let _ = resolve_npm_from_registry(
ResolveNpmRegistryOptions {
specifier: &specifier,
checksum: Some(&tarball_checksum),
detect_path: false,
establish_checksum: false,
registry: ®istry,
config_dir: None,
},
&environment,
)
.await
.unwrap();
assert_eq!(
environment.take_remote_file_auth("https://private.example.com/foo").as_deref(),
Some("Bearer SECRET")
);
assert_eq!(environment.take_remote_file_auth("https://cdn.example.net/foo-1.0.0.tgz"), None);
}
fn stage_per_platform_npm_package(environment: &crate::environment::TestEnvironment, name: &str, version: &str, files: &[(&str, &[u8])]) -> String {
let tarball = create_test_tarball(files);
let checksum = get_sha256_checksum(&tarball);
let packument = serde_json::json!({
"versions": {
version: { "dist": { "tarball": format!("https://registry.npmjs.org/{name}/-/{name}-{version}.tgz") } }
}
});
environment.add_remote_file_bytes(&format!("https://registry.npmjs.org/{name}"), packument.to_string().into_bytes());
environment.add_remote_file_bytes(&format!("https://registry.npmjs.org/{name}/-/{name}-{version}.tgz"), tarball);
checksum
}
#[tokio::test]
async fn resolve_npm_from_node_modules_process_plugin_aarch64_falls_back_to_x86_64() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.set_os("linux");
environment.set_cpu_arch("aarch64");
let tarball_checksum = stage_per_platform_npm_package(&environment, "foo-linux-x86_64", "1.0.0", &[("package/foo", b"fake-binary-contents")]);
let manifest = serde_json::json!({
"schemaVersion": 2,
"name": "foo",
"version": "1.0.0",
"linux-x86_64": {
"reference": "npm:foo-linux-x86_64@1.0.0/foo",
"checksum": tarball_checksum,
},
});
environment.mk_dir_all("/node_modules/foo").unwrap();
environment.write_file("/node_modules/foo/plugin.json", &manifest.to_string()).unwrap();
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: None,
path: "plugin.json".to_string(),
};
let resolved = resolve_npm_from_node_modules(&specifier, std::path::Path::new("/"), &environment)
.await
.unwrap();
let tarball = resolved.pre_resolved_tarball.expect("process plugin should have a pre-resolved tarball");
assert_eq!(tarball.name, "foo");
assert_eq!(tarball.version, "1.0.0");
assert_eq!(tarball.executable_sub_path, "foo");
assert!(!tarball.tarball_bytes.is_empty());
}
#[tokio::test]
async fn resolve_npm_from_node_modules_process_plugin_rejects_unversioned_reference() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.set_os("linux");
environment.set_cpu_arch("x86_64");
let manifest = serde_json::json!({
"schemaVersion": 2,
"name": "foo",
"version": "1.0.0",
"linux-x86_64": {
"reference": "npm:foo-linux-x86_64/foo",
"checksum": "0".repeat(64),
},
});
environment.mk_dir_all("/node_modules/foo").unwrap();
environment.write_file("/node_modules/foo/plugin.json", &manifest.to_string()).unwrap();
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: None,
path: "plugin.json".to_string(),
};
let err = match resolve_npm_from_node_modules(&specifier, std::path::Path::new("/"), &environment).await {
Ok(_) => panic!("expected an error"),
Err(e) => e,
};
let msg = format!("{err:#}");
assert!(msg.contains("must include a version"), "got: {msg}");
assert!(msg.contains("npm:foo-linux-x86_64/foo"), "got: {msg}");
}
#[tokio::test]
async fn resolve_npm_from_node_modules_process_plugin_rejects_tarball_checksum_mismatch() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.set_os("linux");
environment.set_cpu_arch("x86_64");
let _real_checksum = stage_per_platform_npm_package(&environment, "foo-linux-x86_64", "1.0.0", &[("package/foo", b"binary")]);
let bogus_checksum = "0".repeat(64);
let manifest = serde_json::json!({
"schemaVersion": 2,
"name": "foo",
"version": "1.0.0",
"linux-x86_64": {
"reference": "npm:foo-linux-x86_64@1.0.0/foo",
"checksum": bogus_checksum,
},
});
environment.mk_dir_all("/node_modules/foo").unwrap();
environment.write_file("/node_modules/foo/plugin.json", &manifest.to_string()).unwrap();
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: None,
path: "plugin.json".to_string(),
};
let err = match resolve_npm_from_node_modules(&specifier, std::path::Path::new("/"), &environment).await {
Ok(_) => panic!("expected an error"),
Err(e) => e,
};
let msg = format!("{err:#}");
assert!(msg.contains("Invalid checksum"), "got: {msg}");
assert!(msg.contains("foo-linux-x86_64"), "got: {msg}");
}
#[tokio::test]
async fn resolve_npm_from_node_modules_process_plugin_rejects_bad_schema_version() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let manifest = serde_json::json!({
"schemaVersion": 1,
"name": "foo",
"version": "1.0.0",
});
environment.mk_dir_all("/node_modules/foo").unwrap();
environment.write_file("/node_modules/foo/plugin.json", &manifest.to_string()).unwrap();
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: None,
path: "plugin.json".to_string(),
};
let err = match resolve_npm_from_node_modules(&specifier, std::path::Path::new("/"), &environment).await {
Ok(_) => panic!("expected an error"),
Err(e) => e,
};
assert!(
err.to_string().contains("schema version") || format!("{err:#}").contains("schema version"),
"expected schema-version error, got: {err:#}"
);
}
#[tokio::test]
async fn resolve_npm_from_node_modules_process_plugin_rejects_https_reference() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.set_os("linux");
environment.set_cpu_arch("x86_64");
let manifest = serde_json::json!({
"schemaVersion": 2,
"name": "foo",
"version": "1.0.0",
"linux-x86_64": {
"reference": "https://example.com/foo-linux-x86_64.zip",
"checksum": "deadbeef",
},
});
environment.mk_dir_all("/node_modules/foo").unwrap();
environment.write_file("/node_modules/foo/plugin.json", &manifest.to_string()).unwrap();
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: None,
path: "plugin.json".to_string(),
};
let err = match resolve_npm_from_node_modules(&specifier, std::path::Path::new("/"), &environment).await {
Ok(_) => panic!("expected an error"),
Err(e) => e,
};
let msg = format!("{err:#}");
assert!(msg.contains("Network references aren't allowed"), "got: {msg}");
assert!(msg.contains("https://example.com/foo-linux-x86_64.zip"), "got: {msg}");
}
#[tokio::test]
async fn resolve_npm_from_registry_suggests_plugin_json_when_wasm_missing() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let packument = serde_json::json!({
"versions": { "0.6.2": { "dist": { "tarball": "https://registry.npmjs.org/@dprint/exec/-/exec-0.6.2.tgz" } } }
});
environment.add_remote_file_bytes("https://registry.npmjs.org/@dprint/exec", packument.to_string().into_bytes());
let tarball = create_test_tarball(&[("package/plugin.json", br#"{"schemaVersion":2,"name":"exec","version":"0.6.2"}"#)]);
let tarball_checksum = get_sha256_checksum(&tarball);
environment.add_remote_file_bytes("https://registry.npmjs.org/@dprint/exec/-/exec-0.6.2.tgz", tarball);
let registry = NpmRegistryResolution {
url: "https://registry.npmjs.org".to_string(),
auth_header: None,
};
let specifier = NpmSpecifier {
name: "@dprint/exec".to_string(),
version: Some("0.6.2".to_string()),
path: "plugin.wasm".to_string(),
};
let err = match resolve_npm_from_registry(
ResolveNpmRegistryOptions {
specifier: &specifier,
checksum: Some(&tarball_checksum),
detect_path: false,
establish_checksum: false,
registry: ®istry,
config_dir: None,
},
&environment,
)
.await
{
Ok(_) => panic!("expected an error"),
Err(e) => e,
};
let msg = format!("{err:#}");
assert!(msg.contains("plugin.json instead"), "got: {msg}");
assert!(msg.contains("npm:@dprint/exec@0.6.2/plugin.json"), "got: {msg}");
}
#[test]
fn find_npm_plugin_local_path_suggests_plugin_json_when_wasm_missing() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.mk_dir_all("/node_modules/@dprint/exec").unwrap();
environment
.write_file(
"/node_modules/@dprint/exec/plugin.json",
r#"{"schemaVersion":2,"name":"exec","version":"0.6.2"}"#,
)
.unwrap();
let specifier = NpmSpecifier {
name: "@dprint/exec".to_string(),
version: None,
path: "plugin.wasm".to_string(),
};
let err = find_npm_plugin_local_path(&specifier, std::path::Path::new("/"), &environment).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("plugin.json instead"), "got: {msg}");
assert!(msg.contains("npm:@dprint/exec/plugin.json"), "got: {msg}");
}
#[test]
fn find_npm_plugin_local_path_no_alternate_when_neither_present() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
environment.mk_dir_all("/node_modules/foo").unwrap();
environment.write_file("/node_modules/foo/README.md", "hi").unwrap();
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: None,
path: "plugin.wasm".to_string(),
};
let err = find_npm_plugin_local_path(&specifier, std::path::Path::new("/"), &environment).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("Is the package a dprint plugin?"), "got: {msg}");
assert!(!msg.contains("instead"), "got: {msg}");
}
#[tokio::test]
async fn resolve_npm_from_node_modules_missing_package_suggests_versioned_specifier_wasm() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let packument = serde_json::json!({
"dist-tags": { "latest": "0.99.0" },
"versions": { "0.99.0": { "dist": { "tarball": "https://registry.npmjs.org/@dprint/typescript/-/typescript-0.99.0.tgz" } } }
});
environment.add_remote_file_bytes("https://registry.npmjs.org/@dprint/typescript", packument.to_string().into_bytes());
let specifier = NpmSpecifier {
name: "@dprint/typescript".to_string(),
version: None,
path: "plugin.wasm".to_string(),
};
let err = match resolve_npm_from_node_modules(&specifier, std::path::Path::new("/"), &environment).await {
Ok(_) => panic!("expected an error"),
Err(e) => e,
};
let msg = format!("{err:#}");
assert!(msg.contains("Could not find @dprint/typescript in node_modules"), "got: {msg}");
assert!(msg.contains("npm install @dprint/typescript"), "got: {msg}");
assert!(msg.contains("npm:@dprint/typescript@0.99.0"), "got: {msg}");
assert!(!msg.contains("npm:@dprint/typescript@0.99.0@"), "wasm shouldn't carry a checksum: {msg}");
}
#[tokio::test]
async fn resolve_npm_from_node_modules_missing_package_suggests_versioned_specifier_process() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let packument = serde_json::json!({
"dist-tags": { "latest": "0.6.2" },
"versions": { "0.6.2": { "dist": { "tarball": "https://registry.npmjs.org/@dprint/exec/-/exec-0.6.2.tgz" } } }
});
environment.add_remote_file_bytes("https://registry.npmjs.org/@dprint/exec", packument.to_string().into_bytes());
let specifier = NpmSpecifier {
name: "@dprint/exec".to_string(),
version: None,
path: "plugin.json".to_string(),
};
let err = match resolve_npm_from_node_modules(&specifier, std::path::Path::new("/"), &environment).await {
Ok(_) => panic!("expected an error"),
Err(e) => e,
};
let msg = format!("{err:#}");
assert!(msg.contains("Could not find @dprint/exec in node_modules"), "got: {msg}");
assert!(msg.contains("npm:@dprint/exec@0.6.2/plugin.json"), "got: {msg}");
assert!(!msg.contains("npm:@dprint/exec@0.6.2/plugin.json@"), "got: {msg}");
}
#[tokio::test]
async fn resolve_npm_from_node_modules_missing_package_falls_back_when_registry_unreachable() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let specifier = NpmSpecifier {
name: "@dprint/typescript".to_string(),
version: None,
path: "plugin.wasm".to_string(),
};
let err = match resolve_npm_from_node_modules(&specifier, std::path::Path::new("/"), &environment).await {
Ok(_) => panic!("expected an error"),
Err(e) => e,
};
let msg = format!("{err:#}");
assert!(msg.contains("Could not find @dprint/typescript in node_modules"), "got: {msg}");
assert!(msg.contains("npm install @dprint/typescript"), "got: {msg}");
assert!(!msg.contains("OR specify a version (ex."), "got: {msg}");
}
#[tokio::test]
async fn resolve_npm_from_node_modules_process_plugin_rejects_non_process_kind() {
use crate::environment::TestEnvironment;
let environment = TestEnvironment::new();
let manifest = serde_json::json!({
"schemaVersion": 2,
"kind": "other",
"name": "foo",
"version": "1.0.0",
});
environment.mk_dir_all("/node_modules/foo").unwrap();
environment.write_file("/node_modules/foo/plugin.json", &manifest.to_string()).unwrap();
let specifier = NpmSpecifier {
name: "foo".to_string(),
version: None,
path: "plugin.json".to_string(),
};
let err = match resolve_npm_from_node_modules(&specifier, std::path::Path::new("/"), &environment).await {
Ok(_) => panic!("expected an error"),
Err(e) => e,
};
assert!(
format!("{err:#}").contains("Unsupported plugin kind: other"),
"expected unsupported-kind error, got: {err:#}"
);
}
#[cfg(unix)]
#[test]
fn extract_tarball_preserves_exec_bits_via_env_set_permissions() {
use crate::environment::RealEnvironment;
use crate::test_helpers::create_test_npm_tarball_with_modes;
use std::os::unix::fs::PermissionsExt;
RealEnvironment::run_test_with_real_env(|env| {
Box::pin(async move {
let tarball = create_test_npm_tarball_with_modes(&[("package/plugin.wasm", b"wasm", 0o644), ("package/scripts/run.sh", b"#!/bin/sh\n", 0o755)]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("extracted");
extract_tarball_to_dir(&tarball, &dest, &env).unwrap();
let exec_mode = std::fs::metadata(dest.join("scripts").join("run.sh")).unwrap().permissions().mode() & 0o777;
assert_eq!(exec_mode, 0o755, "expected exec bits preserved");
let plain_mode = std::fs::metadata(dest.join("plugin.wasm")).unwrap().permissions().mode() & 0o777;
assert_eq!(plain_mode, 0o644);
})
});
}
}