Skip to content

Commit 75249e0

Browse files
committed
fix(linter/plugins): handle non-UTF8 file paths (#16157)
Fix an edge case bug. Gracefully handle plugins or files being linted that have file paths which are not valid UTF-8. Previously these would cause panics. * Load plugin: Convert plugin path to a `file://...` URL on Rust side, instead of on JS side. File URLs can encode any path via URL escaping. * Lint file: Convert path to `String` with `to_string_lossy`. Avoiding calling `pathToFileURL` on JS side should also be more performant, but that's a very marginal benefit, as loading plugins is a one-time cost in each lint run. This adds a dependency on `url` crate. It's a very popular and battle-tested crate. 430m downloads. https://crates.io/crates/url I could not work out how to add tests for this, as it seems it's not possible to create a file on Mac whose path is not valid UTF-8. It *is* possible on Linux and Windows, but no way to check such a file into Git without it malfunctioning on Mac. We could create test files when tests run, dependent on platform, but I didn't want to create complication in the test infra just for this edge case. It *should* work!
1 parent 9215eb3 commit 75249e0

File tree

8 files changed

+35
-30
lines changed

8 files changed

+35
-30
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ toml = { version = "0.9.8" }
230230
tower-lsp-server = "0.22.1" # LSP server framework
231231
tracing-subscriber = "0.3.20" # Tracing implementation
232232
ureq = { version = "3.1.4", default-features = false } # HTTP client
233+
url = { version = "2.5.7" } # URL parsing
233234
walkdir = "2.5.0" # Directory traversal
234235

235236
[workspace.metadata.cargo-shear]

apps/oxlint/src-js/plugins/load.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { pathToFileURL } from "node:url";
2-
31
import { createContext } from "./context.js";
42
import { getErrorMessage } from "../utils/utils.js";
53

@@ -77,7 +75,7 @@ interface CreateOnceRuleDetails extends RuleDetailsBase {
7775
}
7876

7977
// Absolute paths of plugins which have been loaded
80-
const registeredPluginPaths = new Set<string>();
78+
const registeredPluginUrls = new Set<string>();
8179

8280
// Rule objects for loaded rules.
8381
// Indexed by `ruleId`, which is passed to `lintFile`.
@@ -101,13 +99,13 @@ interface PluginDetails {
10199
*
102100
* Main logic is in separate function `loadPluginImpl`, because V8 cannot optimize functions containing try/catch.
103101
*
104-
* @param path - Absolute path of plugin file
102+
* @param url - Absolute path of plugin file as a `file://...` URL
105103
* @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined)
106104
* @returns Plugin details or error serialized to JSON string
107105
*/
108-
export async function loadPlugin(path: string, packageName: string | null): Promise<string> {
106+
export async function loadPlugin(url: string, packageName: string | null): Promise<string> {
109107
try {
110-
const res = await loadPluginImpl(path, packageName);
108+
const res = await loadPluginImpl(url, packageName);
111109
return JSON.stringify({ Success: res });
112110
} catch (err) {
113111
return JSON.stringify({ Failure: getErrorMessage(err) });
@@ -117,7 +115,7 @@ export async function loadPlugin(path: string, packageName: string | null): Prom
117115
/**
118116
* Load a plugin.
119117
*
120-
* @param path - Absolute path of plugin file
118+
* @param url - Absolute path of plugin file as a `file://...` URL
121119
* @param packageName - Optional package name from `package.json` (fallback if `plugin.meta.name` is not defined)
122120
* @returns - Plugin details
123121
* @throws {Error} If plugin has already been registered
@@ -126,13 +124,13 @@ export async function loadPlugin(path: string, packageName: string | null): Prom
126124
* @throws {TypeError} if `plugin.meta.name` is not a string
127125
* @throws {*} If plugin throws an error during import
128126
*/
129-
async function loadPluginImpl(path: string, packageName: string | null): Promise<PluginDetails> {
127+
async function loadPluginImpl(url: string, packageName: string | null): Promise<PluginDetails> {
130128
if (DEBUG) {
131-
if (registeredPluginPaths.has(path)) throw new Error("This plugin has already been registered");
132-
registeredPluginPaths.add(path);
129+
if (registeredPluginUrls.has(url)) throw new Error("This plugin has already been registered");
130+
registeredPluginUrls.add(url);
133131
}
134132

135-
const { default: plugin } = (await import(pathToFileURL(path).href)) as { default: Plugin };
133+
const { default: plugin } = (await import(url)) as { default: Plugin };
136134

137135
// TODO: Use a validation library to assert the shape of the plugin, and of rules
138136

apps/oxlint/src/js_plugins/external_linter.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ pub fn create_external_linter(
3636
///
3737
/// The returned function will panic if called outside of a Tokio runtime.
3838
fn wrap_load_plugin(cb: JsLoadPluginCb) -> ExternalLinterLoadPluginCb {
39-
Box::new(move |plugin_path, package_name| {
39+
Box::new(move |plugin_url, package_name| {
4040
let cb = &cb;
4141
tokio::task::block_in_place(|| {
4242
tokio::runtime::Handle::current().block_on(async move {
4343
let result = cb
44-
.call_async(FnArgs::from((plugin_path, package_name)))
44+
.call_async(FnArgs::from((plugin_url, package_name)))
4545
.await?
4646
.into_future()
4747
.await?;

crates/oxc_linter/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ serde = { workspace = true, features = ["derive"] }
6969
serde_json = { workspace = true, features = ["preserve_order"] } # preserve_order: print config with ordered keys.
7070
simdutf8 = { workspace = true }
7171
smallvec = { workspace = true }
72+
url = { workspace = true }
7273

7374
[dev-dependencies]
7475
insta = { workspace = true }

crates/oxc_linter/src/config/config_builder.rs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::{
66
use itertools::Itertools;
77
use oxc_resolver::{ResolveOptions, Resolver};
88
use rustc_hash::{FxHashMap, FxHashSet};
9+
use url::Url;
910

1011
use oxc_span::{CompactStr, format_compact_str};
1112

@@ -530,25 +531,25 @@ impl ConfigStoreBuilder {
530531
error: e.to_string(),
531532
}
532533
})?;
533-
// TODO: We should support paths which are not valid UTF-8. How?
534-
let plugin_path = resolved.full_path().to_str().unwrap().to_string();
534+
let plugin_path = resolved.full_path();
535535

536536
if external_plugin_store.is_plugin_registered(&plugin_path) {
537537
return Ok(());
538538
}
539539

540-
// Extract package name from package.json if available
540+
// Extract package name from `package.json` if available
541541
let package_name = resolved.package_json().and_then(|pkg| pkg.name().map(String::from));
542542

543-
let result = {
544-
let plugin_path = plugin_path.clone();
545-
(external_linter.load_plugin)(plugin_path, package_name).map_err(|e| {
546-
ConfigBuilderError::PluginLoadFailed {
547-
plugin_specifier: plugin_specifier.to_string(),
548-
error: e.to_string(),
549-
}
550-
})
551-
}?;
543+
// Convert path to a `file://...` URL, as required by `import(...)` on JS side.
544+
// Note: `unwrap()` here is infallible as `plugin_path` is an absolute path.
545+
let plugin_url = Url::from_file_path(&plugin_path).unwrap().as_str().to_string();
546+
547+
let result = (external_linter.load_plugin)(plugin_url, package_name).map_err(|e| {
548+
ConfigBuilderError::PluginLoadFailed {
549+
plugin_specifier: plugin_specifier.to_string(),
550+
error: e.to_string(),
551+
}
552+
})?;
552553

553554
match result {
554555
PluginLoadResult::Success { name, offset, rule_names } => {

crates/oxc_linter/src/external_plugin_store.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::fmt;
1+
use std::{
2+
fmt,
3+
path::{Path, PathBuf},
4+
};
25

36
use rustc_hash::{FxHashMap, FxHashSet};
47

@@ -14,7 +17,7 @@ define_index_type! {
1417

1518
#[derive(Debug)]
1619
pub struct ExternalPluginStore {
17-
registered_plugin_paths: FxHashSet<String>,
20+
registered_plugin_paths: FxHashSet<PathBuf>,
1821

1922
plugins: IndexVec<ExternalPluginId, ExternalPlugin>,
2023
plugin_names: FxHashMap<String, ExternalPluginId>,
@@ -51,7 +54,7 @@ impl ExternalPluginStore {
5154
self.plugins.is_empty()
5255
}
5356

54-
pub fn is_plugin_registered(&self, plugin_path: &str) -> bool {
57+
pub fn is_plugin_registered(&self, plugin_path: &Path) -> bool {
5558
self.registered_plugin_paths.contains(plugin_path)
5659
}
5760

@@ -63,7 +66,7 @@ impl ExternalPluginStore {
6366
/// - `offset` does not equal the number of registered rules.
6467
pub fn register_plugin(
6568
&mut self,
66-
plugin_path: String,
69+
plugin_path: PathBuf,
6770
plugin_name: String,
6871
offset: usize,
6972
rule_names: Vec<String>,

crates/oxc_linter/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ impl Linter {
462462
};
463463

464464
let result = (external_linter.lint_file)(
465-
path.to_str().unwrap().to_string(),
465+
path.to_string_lossy().into_owned(),
466466
external_rules.iter().map(|(rule_id, _)| rule_id.raw()).collect(),
467467
settings_json,
468468
allocator,

0 commit comments

Comments
 (0)