char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somdeslop: factor cjs resolution and on-demand loading into commonjs.rsac70d72

main
20.1 KiB499 linesraw
1use std::path::{Path, PathBuf};
2
3use avril_core::{Error, ModuleLoader};
4
5use crate::caps::PathTarget;
6use crate::modules::{ModuleGraph, ResolutionMode, lexical_absolute};
7
8use oxc::allocator::Allocator;
9use oxc::parser::Parser;
10use oxc::span::SourceType;
11
12/// Static CommonJS named exports.
13pub(crate) fn named_exports(source: &str) -> Vec<String> {
14    use oxc::ast::ast::{
15        AssignmentTarget, CallExpression, Expression, ObjectPropertyKind, PropertyKey,
16    };
17    use oxc::ast_visit::{Visit, walk};
18    use oxc::syntax::identifier::is_identifier_name;
19
20    fn valid_name(name: &str) -> bool {
21        // Synthetic ESM wrappers need importable identifiers; string-keyed
22        // properties like `exports["foo-bar"]` stay available via `default`.
23        !name.is_empty() && is_identifier_name(name)
24    }
25
26    fn string_key(key: &PropertyKey<'_>) -> Option<String> {
27        match key {
28            PropertyKey::StaticIdentifier(identifier) => Some(identifier.name.to_string()),
29            PropertyKey::StringLiteral(literal) => Some(literal.value.to_string()),
30            _ => None,
31        }
32    }
33
34    fn expr_is_exports(expr: &Expression<'_>) -> bool {
35        matches!(expr, Expression::Identifier(identifier) if identifier.name == "exports")
36    }
37
38    fn expr_is_module_exports(expr: &Expression<'_>) -> bool {
39        match expr {
40            Expression::StaticMemberExpression(member) => {
41                matches!(&member.object, Expression::Identifier(object) if object.name == "module")
42                    && member.property.name == "exports"
43            }
44            _ => false,
45        }
46    }
47
48    #[derive(Default)]
49    struct Collector {
50        names: std::collections::BTreeSet<String>,
51    }
52
53    impl<'a> Visit<'a> for Collector {
54        fn visit_assignment_expression(&mut self, expr: &oxc::ast::ast::AssignmentExpression<'a>) {
55            let mut direct: Option<String> = None;
56            let mut object_assign: Option<&Expression<'a>> = None;
57            match &expr.left {
58                AssignmentTarget::StaticMemberExpression(member) => {
59                    let prop = member.property.name.to_string();
60                    if expr_is_exports(&member.object) || expr_is_module_exports(&member.object) {
61                        direct = Some(prop);
62                    } else if matches!(&member.object, Expression::Identifier(object) if object.name == "module")
63                        && member.property.name == "exports"
64                    {
65                        // `module.exports = …` itself; object literal handled below.
66                        object_assign = Some(&expr.right);
67                    }
68                }
69                AssignmentTarget::ComputedMemberExpression(member) => {
70                    let prop = match &member.expression {
71                        Expression::StringLiteral(literal) => Some(literal.value.to_string()),
72                        _ => None,
73                    };
74                    if let Some(prop) = prop
75                        && (expr_is_exports(&member.object)
76                            || expr_is_module_exports(&member.object))
77                    {
78                        direct = Some(prop);
79                    }
80                    // `module.exports = …` via computed `module["exports"]` is rare; skip.
81                }
82                _ => {}
83            }
84            if let Some(name) = direct
85                && valid_name(&name)
86            {
87                self.names.insert(name);
88            }
89            // `module.exports = { a, b: …, "c": … }`.
90            if let Some(right) = object_assign
91                && let Expression::ObjectExpression(object) = right
92            {
93                for property in &object.properties {
94                    match property {
95                        ObjectPropertyKind::ObjectProperty(property) => {
96                            if property.computed {
97                                continue;
98                            }
99                            if let Some(name) = string_key(&property.key)
100                                && valid_name(&name)
101                            {
102                                self.names.insert(name);
103                            }
104                        }
105                        ObjectPropertyKind::SpreadProperty(_) => {}
106                    }
107                }
108            }
109            walk::walk_assignment_expression(self, expr);
110        }
111
112        fn visit_call_expression(&mut self, expr: &CallExpression<'a>) {
113            if let Expression::StaticMemberExpression(callee) = &expr.callee
114                && matches!(&callee.object, Expression::Identifier(object) if object.name == "Object")
115                && callee.property.name == "defineProperty"
116                && expr.arguments.len() >= 2
117            {
118                let target = expr.arguments.first().and_then(|arg| arg.as_expression());
119                let name = expr.arguments.get(1).and_then(|arg| arg.as_expression());
120                let is_exports_target = target.is_some_and(|target| {
121                    expr_is_exports(target) || expr_is_module_exports(target)
122                });
123                if is_exports_target && let Some(Expression::StringLiteral(literal)) = name {
124                    let name = literal.value.to_string();
125                    if valid_name(&name) {
126                        self.names.insert(name);
127                    }
128                }
129            }
130            walk::walk_call_expression(self, expr);
131        }
132    }
133
134    let allocator = Allocator::default();
135    let parsed = Parser::new(&allocator, source, SourceType::cjs()).parse();
136    // On syntax errors return what was found; execution reports the error.
137    let mut collector = Collector::default();
138    collector.visit_program(&parsed.program);
139    collector.names.into_iter().collect()
140}
141
142enum LocalLookup {
143    Missing,
144    Err(Error),
145}
146
147impl ModuleGraph {
148    /// Shared synchronous source for `require` demand loads: verified npm
149    /// bytes via `npm_source` (virtual-URL authority + integrity) or local
150    /// files via `read_text` (source authority, no broader grant needed).
151    /// Never performs network I/O.
152    pub(crate) fn sync_source(&self, name: &str) -> Result<String, Error> {
153        self.check_access(name)?;
154        let source = if self.npm.owning_instance(Path::new(name)).is_some() {
155            self.npm
156                .npm_source(Path::new(name))
157                .map_err(|error| Error::Module(error.to_string()))?
158        } else if self.npm.is_reserved_npm_path(Path::new(name)) {
159            return Err(Error::Module(format!(
160                "npm cache path is not owned by a prepared instance: {name}"
161            )));
162        } else {
163            crate::sources::read_text(&self.target(name)?, &self.authority)
164                .map_err(|error| self.path_error(name, error))?
165        };
166        self.check_access(name)?;
167        Ok(source)
168    }
169
170    pub(crate) fn require_referrer_path(referrer: &str) -> Option<PathBuf> {
171        let path = Path::new(referrer);
172        if path.is_absolute() {
173            return lexical_absolute(path).ok();
174        }
175        if let Ok(url) = url::Url::parse(referrer)
176            && url.scheme() == "file"
177        {
178            return lexical_absolute(&url.to_file_path().ok()?).ok();
179        }
180        None
181    }
182
183    fn resolve_npm_for_anchor(
184        &self,
185        anchor: &str,
186        npm_specifier: &str,
187        original: &str,
188    ) -> Result<String, Error> {
189        let root = self
190            .packages
191            .root(anchor)
192            .map_err(|error| Error::Module(error.to_string()))?;
193        let handles = self.npm_handles.borrow();
194        let Some(handle) = handles.get(&root) else {
195            return Err(Error::Module(format!(
196                "npm package is not prepared in this context (sync require cannot acquire new packages): {original}"
197            )));
198        };
199        self.npm
200            .resolve_in(handle, npm_specifier, ResolutionMode::Require)
201            .map(|resolved| {
202                resolved
203                    .path
204                    .to_str()
205                    .expect("npm paths are UTF-8")
206                    .to_owned()
207            })
208            .map_err(|error| Error::Module(error.to_string()))
209    }
210
211    fn resolve_local_require(
212        &self,
213        specifier: &str,
214        referrer_path: &Path,
215    ) -> Result<String, Error> {
216        let path = if specifier
217            .get(..5)
218            .is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:"))
219        {
220            let url = url::Url::parse(specifier)
221                .map_err(|_| Error::Module(format!("Invalid file URL: {specifier}")))?;
222            if url.query().is_some() || url.fragment().is_some() {
223                return Err(Error::Module(
224                    "File URL queries and fragments are not supported yet".into(),
225                ));
226            }
227            url.to_file_path()
228                .map_err(|_| Error::Module(format!("Invalid file URL: {specifier}")))?
229        } else {
230            let path = Path::new(specifier);
231            if path.is_absolute() {
232                path.to_owned()
233            } else if specifier.starts_with("./") || specifier.starts_with("../") {
234                referrer_path.parent().unwrap_or(Path::new(".")).join(path)
235            } else {
236                return Err(Error::Module(format!(
237                    "Unsupported module specifier for sync require (use import() instead): {specifier}"
238                )));
239            }
240        };
241        match self.local_file_require(&path, 0) {
242            Ok(name) => Ok(name),
243            Err(LocalLookup::Missing) => {
244                Err(self.path_error(specifier, format!("Module not found: {}", path.display())))
245            }
246            Err(LocalLookup::Err(error)) => Err(error),
247        }
248    }
249
250    fn local_file_require(&self, path: &Path, depth: usize) -> Result<String, LocalLookup> {
251        if depth > 5 {
252            return Err(LocalLookup::Err(Error::Module(
253                "Local require lookup is too deep".into(),
254            )));
255        }
256        match self.local_candidate(path) {
257            Ok(name) => return Self::check_native(name).map_err(LocalLookup::Err),
258            Err(LocalLookup::Missing) => {}
259            Err(other) => return Err(other),
260        }
261        for extension in [".js", ".json", ".node"] {
262            let mut candidate = path.as_os_str().to_owned();
263            candidate.push(extension);
264            let candidate_path = PathBuf::from(candidate);
265            match self.local_candidate(&candidate_path) {
266                Ok(name) => return Self::check_native(name).map_err(LocalLookup::Err),
267                Err(LocalLookup::Missing) => continue,
268                Err(other) => return Err(other),
269            }
270        }
271        if self.local_is_dir(path) {
272            match self.local_dir_main(path) {
273                Ok(Some(main)) => {
274                    let candidate = path.join(main);
275                    match self.local_file_require(&candidate, depth + 1) {
276                        Ok(name) => return Ok(name),
277                        Err(LocalLookup::Missing) => {}
278                        Err(other) => return Err(other),
279                    }
280                }
281                Ok(None) => {}
282                Err(error) => return Err(LocalLookup::Err(error)),
283            }
284            for index in ["index.js", "index.json", "index.cjs", "index.node"] {
285                match self.local_candidate(&path.join(index)) {
286                    Ok(name) => return Self::check_native(name).map_err(LocalLookup::Err),
287                    Err(LocalLookup::Missing) => continue,
288                    Err(other) => return Err(other),
289                }
290            }
291        }
292        Err(LocalLookup::Missing)
293    }
294
295    fn check_native(name: String) -> Result<String, Error> {
296        if name.to_ascii_lowercase().ends_with(".node") {
297            return Err(Error::Module(format!(
298                "Native addons are not supported: {name}"
299            )));
300        }
301        Ok(name)
302    }
303
304    fn local_candidate(&self, path: &Path) -> Result<String, LocalLookup> {
305        let logical = lexical_absolute(path).map_err(LocalLookup::Err)?;
306        if self.npm.is_reserved_npm_path(&logical) {
307            return self.npm_local_candidate(&logical);
308        }
309        let target = match PathTarget::resolve(path) {
310            Ok(target) => target,
311            Err(_) => return Err(LocalLookup::Missing),
312        };
313        if self.npm.is_reserved_npm_path(target.as_path()) {
314            return self.npm_local_candidate(target.as_path());
315        }
316        if let Err(error) = self.authority.check_local(&target) {
317            return Err(LocalLookup::Err(
318                self.path_error(&path.display().to_string(), error),
319            ));
320        }
321        let resolved = target.as_path().to_owned();
322        if !std::fs::symlink_metadata(&resolved).is_ok_and(|meta| meta.is_file()) {
323            return Err(LocalLookup::Missing);
324        }
325        resolved
326            .into_os_string()
327            .into_string()
328            .map_err(|_| LocalLookup::Err(Error::Module("Module paths must be valid UTF-8".into())))
329    }
330
331    fn npm_local_candidate(&self, path: &Path) -> Result<String, LocalLookup> {
332        match self.npm.resolve_require_path(path) {
333            Ok(Some(module)) => module.path.into_os_string().into_string().map_err(|_| {
334                LocalLookup::Err(Error::Module("Module paths must be valid UTF-8".into()))
335            }),
336            Ok(None) => Err(LocalLookup::Missing),
337            Err(error) => Err(LocalLookup::Err(Error::Module(error.to_string()))),
338        }
339    }
340
341    fn local_is_dir(&self, path: &Path) -> bool {
342        PathTarget::resolve(path)
343            .ok()
344            .filter(|target| self.authority.check_local(target).is_ok())
345            .is_some_and(|target| target.as_path().is_dir())
346    }
347
348    fn local_dir_main(&self, dir: &Path) -> Result<Option<PathBuf>, Error> {
349        let package = dir.join("package.json");
350        let target = match PathTarget::resolve(&package) {
351            Ok(target) => target,
352            Err(_) => return Ok(None),
353        };
354        let Some(name) = target.as_path().to_str() else {
355            return Ok(None);
356        };
357        let source = self.sync_source(name)?;
358        let value: serde_json::Value = serde_json::from_str(&source).map_err(|error| {
359            Error::Module(format!(
360                "{}: invalid package.json: {error}",
361                package.display()
362            ))
363        })?;
364        let Some(main) = value.get("main").and_then(|entry| entry.as_str()) else {
365            return Ok(None);
366        };
367        if main.is_empty() || main.contains('\0') {
368            return Err(Error::Module(format!(
369                "{}: invalid package.json main",
370                package.display()
371            )));
372        }
373        if main.contains("../") || main.starts_with("..") {
374            return Ok(None);
375        }
376        let main = main.strip_prefix("./").unwrap_or(main);
377        if main.is_empty()
378            || main
379                .split('/')
380                .any(|part| part.is_empty() || part == "." || part == "..")
381        {
382            return Ok(None);
383        }
384        Ok(Some(PathBuf::from(main)))
385    }
386    pub(crate) fn require_resolve_sync(
387        &self,
388        specifier: &str,
389        referrer: &str,
390    ) -> Result<String, Error> {
391        if specifier.starts_with("ext:") {
392            return Err(Error::Module("Extension modules are private".into()));
393        }
394        if specifier.starts_with("node:") || specifier == "node" {
395            return Err(Error::Module(format!(
396                "node: builtin modules are not supported: {specifier}"
397            )));
398        }
399        if specifier.starts_with("avril:") {
400            return Ok(specifier.to_owned());
401        }
402        // Never expose internal extension modules via any path.
403        // `require_resolve` itself performs no I/O beyond verified metadata
404        // and canonicalized filesystem probes; capability checks happen in
405        // the core `require` wrapper (and `require.resolve`) around this.
406        if let Some(referrer_path) = Self::require_referrer_path(referrer) {
407            if self.npm.is_reserved_npm_path(&referrer_path) {
408                if self.npm.owning_instance(&referrer_path).is_none() {
409                    return Err(Error::Module(format!(
410                        "npm cache path is not owned by a prepared instance: {}",
411                        referrer_path.display()
412                    )));
413                }
414                return self
415                    .npm
416                    .resolve_from_file(specifier, &referrer_path, ResolutionMode::Require)
417                    .map(|resolved| {
418                        resolved
419                            .path
420                            .to_str()
421                            .expect("npm paths are UTF-8")
422                            .to_owned()
423                    })
424                    .map_err(|error| Error::Module(error.to_string()));
425            }
426            let anchor = referrer_path
427                .to_str()
428                .ok_or_else(|| Error::Module("Module paths must be valid UTF-8".into()))?;
429            if let Some(mapped) =
430                self.packages
431                    .resolve_import_meta(specifier, anchor, &self.remote)?
432            {
433                match mapped {
434                    crate::local_packages::Resolution::Specifier(target) => {
435                        if target.starts_with("npm:") {
436                            return self.resolve_npm_for_anchor(anchor, &target, specifier);
437                        }
438                        if target.starts_with("ext:") {
439                            return Err(Error::Module("Extension modules are private".into()));
440                        }
441                        if target.starts_with("node:") || target == "node" {
442                            return Err(Error::Module(format!(
443                                "node: builtin modules are not supported: {specifier}"
444                            )));
445                        }
446                        if target.starts_with("avril:")
447                            || target.starts_with("jsr:")
448                            || target.starts_with("http:")
449                            || target.starts_with("https:")
450                        {
451                            return Err(Error::Module(format!(
452                                "Unsupported module specifier for sync require (use import() instead): {specifier}"
453                            )));
454                        }
455                        return self.resolve_local_require(&target, &referrer_path);
456                    }
457                    crate::local_packages::Resolution::Module(name) => {
458                        if name.starts_with("ext:") {
459                            return Err(Error::Module("Extension modules are private".into()));
460                        }
461                        return Ok(name);
462                    }
463                    crate::local_packages::Resolution::Remote(_) => {
464                        return Err(Error::Module(format!(
465                            "Unsupported module specifier for sync require (use import() instead): {specifier}"
466                        )));
467                    }
468                }
469            }
470            if specifier.starts_with("npm:") {
471                return self.resolve_npm_for_anchor(anchor, specifier, specifier);
472            }
473            return self.resolve_local_require(specifier, &referrer_path);
474        }
475        Err(Error::Module(format!(
476            "Unsupported module specifier for sync require (use import() instead): {specifier}"
477        )))
478    }
479}
480
481#[cfg(test)]
482mod require_referrer_tests {
483    use super::ModuleGraph;
484
485    #[test]
486    fn referrer_paths_are_portable() {
487        assert!(ModuleGraph::require_referrer_path("/tmp/a.cjs").is_some());
488        assert!(ModuleGraph::require_referrer_path("file:///tmp/a.cjs").is_some());
489        assert!(ModuleGraph::require_referrer_path("./a.cjs").is_none());
490        assert!(ModuleGraph::require_referrer_path("https://example.test/a.js").is_none());
491        assert!(ModuleGraph::require_referrer_path("avril:std").is_none());
492    }
493
494    #[cfg(windows)]
495    #[test]
496    fn windows_absolute_paths_resolve() {
497        assert!(ModuleGraph::require_referrer_path(r"C:\proj\a.cjs").is_some());
498    }
499}