From 93cad3b5d22b49beef78109861663b9da8895e89 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 5 Aug 2026 18:06:59 -0700 Subject: [PATCH] feat(compartment-mapper): support dynamic imports of absolute paths & windows paths This adds support for dynamically importing absolute paths (via `import()`) to `@endo/compartment-mapper`. It also adds support for Windows-style paths in both the dynamic `import()` and `require()` use-cases, depending on supplied `ReadPowers`. Added relevant sections to `README.md` and reformatted (fixing headers & the like). --- .changeset/dynamic-absolute-imports-win32.md | 11 + packages/compartment-mapper/README.md | 191 +++++++++---- .../compartment-mapper/src/import-hook.js | 108 ++++++-- packages/compartment-mapper/src/link.js | 30 +- .../test/dynamic-import-esm.test.js | 260 ++++++++++++++++++ .../test/dynamic-require.test.js | 72 +++++ .../node_modules/app/index.js | 7 + .../node_modules/app/lib/bar.js | 1 + .../node_modules/app/package.json | 3 + .../node_modules/dep/index.js | 1 + .../node_modules/dep/other.js | 1 + .../node_modules/dep/package.json | 9 + 12 files changed, 617 insertions(+), 77 deletions(-) create mode 100644 .changeset/dynamic-absolute-imports-win32.md create mode 100644 packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/lib/bar.js create mode 100644 packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/index.js create mode 100644 packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/other.js create mode 100644 packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/package.json diff --git a/.changeset/dynamic-absolute-imports-win32.md b/.changeset/dynamic-absolute-imports-win32.md new file mode 100644 index 0000000000..b202d92662 --- /dev/null +++ b/.changeset/dynamic-absolute-imports-win32.md @@ -0,0 +1,11 @@ +--- +'@endo/compartment-mapper': minor +--- + +Added support for dynamic `import()` of absolute paths (POSIX/Win32). Added support for absolute Win32 paths to dynamic `require()`. + +Dynamic `import()` of absolute paths in a POSIX environment requires a `ReadPowers` object containing an `isAbsolute` function (`IsAbsoluteFn`). A `pathToFileURL` function (`PathToFileURLFn`) is strongly recommended. + +For absolute path support in a Windows environment (for both dynamic `import()` and `require()`), a `ReadPowers` object _must_ supply `isAbsolute` and `pathToFileURL`. + +Dynamic `require()` support for absolute paths retains the current baseline `ReadPowers` object requirements; see [`README.md`](https://github.com/endojs/endo/blob/master/packages/compartment-mapper/README.md) for details. diff --git a/packages/compartment-mapper/README.md b/packages/compartment-mapper/README.md index e70962b62b..5283d889f5 100644 --- a/packages/compartment-mapper/README.md +++ b/packages/compartment-mapper/README.md @@ -1,6 +1,6 @@ -# Compartment mapper +# @endo/compartment-mapper -The compartment mapper builds _compartment maps_ for Node.js style +The `@endo/compartment-mapper` package builds _compartment maps_ for Node.js-style applications, finding their dependencies and describing how to create [Compartments][] for each package in the application. @@ -21,9 +21,9 @@ The `modules` are built-in modules to grant the application's main package compartment. ```js -import fs from "node:fs"; -import { fileURLToPath } from "node:url"; -import { importLocation } from "@endo/compartment-mapper"; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { importLocation } from '@endo/compartment-mapper'; // ... @@ -54,17 +54,17 @@ available, `__dirname` and `__filename` will be provided to CJS modules. When ```ts type ReadPowers = { - read: (location: string) => Promise, - canonical: (location: string) => Promise, - computeSha512?: (bytes: Uint8Array) => string, - fileURLToPath?: (location: string | URL) => string, - pathToFileURL?: (path: string) => URL, + read: (location: string) => Promise; + canonical: (location: string) => Promise; + computeSha512?: (bytes: Uint8Array) => string; + fileURLToPath?: (location: string | URL) => string; + pathToFileURL?: (path: string) => URL; requireResolve?: ( fromLocation: string, specifier: string, options?: { paths?: string[] }, - ) => string -} + ) => string; +}; ``` > [!NOTE] @@ -84,9 +84,9 @@ Use `writeArchive` to capture an application in an archival format. Archives are `zip` files with a `compartment-map.json` manifest file. ```js -import fs from "node:fs"; -import { fileURLToPath } from "node:url"; -import { writeArchive } from "@endo/compartment-mapper"; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { writeArchive } from '@endo/compartment-mapper'; const read = async location => fs.promises.readFile(fileURLToPath(location)); const write = async (location, content) => @@ -108,9 +108,9 @@ Use `importArchive` to run an application from an archive. Note the similarity to `importLocation`. ```js -import fs from "node:fs"; -import { fileURLToPath } from "node:url"; -import { importArchive } from "@endo/compartment-mapper"; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { importArchive } from '@endo/compartment-mapper'; // ... @@ -137,7 +137,7 @@ Use `parseArchive` to construct a runner from the bytes of an archive. `loadArchive` and `parseArchive` do not run the archived application, so they can be used to safely check its hash. -# Script bundles +## Script bundles From `@endo/compartment-mapper/script.js`, the `makeScript` function is similar to `makeArchive` but generates a string of JavaScript suitable for `eval` or @@ -148,10 +148,10 @@ script that subsumes `ses`, `@endo/compartment-mapper/import-archive.js`, and other parts of Endo, but is not as feature-complete as `importArchive`. ```js -import url from "node:url"; -import fs from "node:fs"; -import { makeScript } from "@endo/compartment-mapper/script.js"; -import { makeReadPowers } from "@endo/compartment-mapper/node-powers.js"; +import url from 'node:url'; +import fs from 'node:fs'; +import { makeScript } from '@endo/compartment-mapper/script.js'; +import { makeReadPowers } from '@endo/compartment-mapper/node-powers.js'; const readPowers = makeReadPowers({ fs, url }); const options = {}; // if any const script = await makeScript(readPowers, moduleLocation, options); @@ -162,7 +162,7 @@ The script is in UTF-8 format and uses non-ASCII characters, so may require headers or tags to specify the encoding. ```html - + ``` @@ -264,10 +264,10 @@ location. The `-lite.js` modules, in general, do not entrain a specific compartment mapper. -# Functor bundles +## Functor bundles From `@endo/compartment-mapper/functor.js`, the `makeFunctor` function is similar -to `makeScript` but generates a string of JavaScript suitable for `eval` but *not* +to `makeScript` but generates a string of JavaScript suitable for `eval` but _not_ suitable for embedding as a script. But, the completion value of the script is a function that accepts runtime options and returns the entry module's emulated module exports namespace, adding a level of indirection. @@ -304,7 +304,7 @@ location. The `-lite.js` modules, in general, do not entrain a specific compartment mapper. -# Host module exits in bundles +## Host module exits in bundles When bundling for an archive, the compartment mapper implicitly treats any module specifier whose prefix matches a URI scheme (per [RFC 3986 section @@ -319,7 +319,7 @@ implementation via `importHook` when the archive is later imported. Host-provided modules must be hardened and pure to avoid being a side-channel or man-in-the-middle attack surface between guests. -# Package Descriptors +## Package Descriptors The compartment mapper uses [Compartments], one for each Node.js package your application needs. @@ -332,13 +332,13 @@ Each package has its own descriptor, `package.json`. Some standard properties of the descriptor are relevant and used by a compartment map. -* `name` -* `type` -* `main` -* `exports` -* `browser` -* `dependencies` -* `files` +- `name` +- `type` +- `main` +- `exports` +- `browser` +- `dependencies` +- `files` The compartment map will contain one compartment for each `package.json` necessary to build the application. @@ -487,7 +487,7 @@ override the extension-to-language mapping. > module-to-module translator and endow the compartment with the `h` the > translated modules need. -# Source Maps +## Source Maps The `makeArchive`, `makeAndHashArchive`, and `writeArchive` tools can receive a `sourceMapHook` as one of its options. @@ -507,41 +507,118 @@ These will be appended to each module from the archive, for debugging purposes. The `@endo/bundle-source` and `@endo/import-bundle` tools integrate source maps for an end-to-end debugging experience. -# XS (experimental) + +## Runtime-computed `import()` specifiers + +ESM modules may call `await import(specifier)` where `specifier` is not known +statically. The compartment mapper supports the following specifier kinds at +runtime, with the noted `readPowers` requirements: + +| Specifier kind | Example | Required `readPowers` | +| ---------------------------------------- | ------------------------------------------- | -------------------------------------------------------------- | +| Bare package name | `'dep'` | none (declared dependency) | +| Bare subpath | `'dep/util.js'` | none (declared dependency) | +| Relative path | `'./sibling.js'` | none | +| `file:` URL (within compartment) | `new URL('./foo.js', import.meta.url).href` | none — URL arithmetic only | +| `file:` URL (cross-compartment) | `file:///abs/path/dep/index.js` | none, but requires a policy that allows access | +| Absolute POSIX path (within compartment) | `/abs/path/pkg/foo.js` | `isAbsolute` | +| Absolute POSIX path (cross-compartment) | `/abs/path/dep/index.js` | `isAbsolute` + a permitting policy | +| Absolute Windows path | `C:\path\pkg\foo.js` | `isAbsolute` **and** `pathToFileURL`, both in a Windows flavor | + +**Cross-compartment access** (loading a module from a different package via an +absolute specifier) requires a [policy] that explicitly permits the access. +Without a policy, the import will throw `Could not import module`. + +**Pass `path` to get absolute-path support.** `makeReadPowers` and +`makeReadNowPowers` substitute a stub `isAbsolute` that always answers `false` +when the `path` option is omitted, which suppresses every absolute specifier +regardless of what else is provided. `file:` URLs are unaffected, needing no +powers at all. + +**Absolute paths degrade silently.** An absolute specifier is +indistinguishable from a bare package name until `isAbsolute` says otherwise, +so when the necessary powers are missing it is treated as a package name and +reported as a missing external module rather than as a missing power. +Diagnosing the latter would require the very power that is absent. + +`pathToFileURL` is optional but recommended. Without it a leading-slash path +is still converted by URL arithmetic, which suffices on POSIX except for +filenames containing `#` or `?`; a Windows path cannot be converted at all, +because the URL parser reads its drive letter as a scheme. + +**Archive/bundle mode** does not support runtime-computed `file:` URL or +absolute-path specifiers because the module graph is fixed at archive creation +time. The supported specifier kinds for archives are bare package names, +subpaths, and relative paths. + +## Runtime-computed `require()` specifiers + +CJS modules may likewise call `require(specifier)` with a specifier computed at +runtime. This is common in the wild: a package computes `__dirname + '/x.js'`, +or hands a directory to a loader that picks a file out of it. + +Support is opt-in, because `require()` is synchronous and so demands +synchronous read powers. Supply an `importNowHook`, and `readPowers` that +qualify as `ReadNowPowers` by providing `maybeReadNow`, `fileURLToPath`, and +`isAbsolute`. If any are missing, dynamic require is not enabled at all and the +error names the offending properties + +The specifier kinds and their resolution rules match the `import()` table +above, including Windows absolute paths, with one significant difference: + +**Windows path support additionally requires `pathToFileURL` in `readPowers`.** + +> [!TIP] +> +> In Node.js, you can use `makeReadNowPowers` from +> `@endo/compartment-mapper/node-powers.js` to get a `ReadNowPowers` object that +> satisfies the requirements. +> +> ```js +> import fs from 'node:fs'; +> import url from 'node:url'; +> import path from 'node:path'; +> import crypto from 'node:crypto'; +> import { makeReadNowPowers } from '@endo/compartment-mapper/node-powers.js'; +> +> const readPowers = makeReadNowPowers({ fs, url, path, crypto }); +> ``` + +## XS (experimental) The Compartment Mapper can use native XS `Compartment` and `ModuleSource` under certain conditions: 1. The application must be an XS script that was compiled with the `xs` - package condition. - This causes `ses`, `@endo/module-source`, and `@endo/import-bundle` to - provide slightly different implementations that can fall through to native - behavior. + package condition. + This causes `ses`, `@endo/module-source`, and `@endo/import-bundle` to + provide slightly different implementations that can fall through to native + behavior. 2. The application must opt-in with the `__native__: true` option on any - of the compartment mapper methods that import modules like `importLocation` - and `importArchive`. + of the compartment mapper methods that import modules like `importLocation` + and `importArchive`. -# Design +## Design Each workflow of the compartment mapper executes a portion of a sequence of underlying internals. -* search ([search.js](./src/search.js)): Scan the parent directories of a given +- search ([search.js](./src/search.js)): Scan the parent directories of a given `moduleLocation` until successfully finding and reading a `package.json` for the containing application. -* map compartments from Node.js packages +- map compartments from Node.js packages ([node-modules.js](./src/node-modules.js)): Find and gather all the `package.json` files for the application's transitive dependencies. Use these to construct a compartment map describing how to construct a `Compartment` for each application package and how to link the modules each exports in the compartments that import them. -* load compartments ([archive.js](./src/archive.js)): Using `compartment.load`, +- load compartments ([archive.js](./src/archive.js)): Using `compartment.load`, or implicitly through `compartment.import`, create a module graph for the application's entire working set. When creating an archive, this does not execute any of the modules. The compartment mapper uses the compartments and a special `importHook` that records the text of every module the main module needed. -* import modules ([import.js](./src/import.js), +- import modules ([import.js](./src/import.js), [import-archive.js](./src/import-archive.js)): Actually execute the working set. @@ -562,7 +639,7 @@ capabilities to a compartment graph. For `writeArchive` and `makeArchive`, these may be provided but will be ignored since the application does not execute. -``` +```text loadLocation writeArchive importLocation | | makeArchive | | | | @@ -583,7 +660,7 @@ assemble compartments -> * * * : * : : <- powers '...............' ``` -# Compartment maps +## Compartment maps The compartment mapper works by generating a _compartment map_ from your application workspace and all of the `node_modules` it needs. @@ -768,7 +845,7 @@ type RealmName = string; type ModuleParameter = string; ``` -# Compartment map policy +## Compartment map policy The `policy` option accepted by the compartment-mapper API methods provides means to narrow down the endowments passed to each compartment independently. The rules defined by policy get preserved in the compartment map and enforced in the application. To explore how policies work, see [Policy Demo]. @@ -780,9 +857,9 @@ The shape of the `policy` object is based on `policy.json` from LavaMoat. MetaMa > policy.json. > Policy generation may be ported to Endo. - [LavaMoat]: https://github.com/LavaMoat/lavamoat - [Compartments]: ../ses/README.md#compartment - [Policy Demo]: ./demo/policy/README.md - [import attributes]: https://nodejs.org/docs/latest/api/esm.html#import-attributes - [package entry points]: https://nodejs.org/api/esm.html#esm_package_entry_points - [`require.resolve()`]: https://nodejs.org/docs/latest/api/modules.html#requireresolverequest-options +[LavaMoat]: https://github.com/LavaMoat/lavamoat +[Compartments]: ../ses/README.md#compartment +[Policy Demo]: ./demo/policy/README.md +[import attributes]: https://nodejs.org/docs/latest/api/esm.html#import-attributes +[package entry points]: https://nodejs.org/api/esm.html#esm_package_entry_points +[`require.resolve()`]: https://nodejs.org/docs/latest/api/modules.html#requireresolverequest-options diff --git a/packages/compartment-mapper/src/import-hook.js b/packages/compartment-mapper/src/import-hook.js index fb3c040af3..3e6733e7e5 100644 --- a/packages/compartment-mapper/src/import-hook.js +++ b/packages/compartment-mapper/src/import-hook.js @@ -54,6 +54,8 @@ import { unpackReadPowers } from './powers.js'; * ModuleSourceHook, * ParseFn, * AsyncParseFn, + * MaybeReadPowers, + * PackageCompartmentDescriptor, * } from './types.js' */ @@ -115,6 +117,52 @@ const nodejsConventionSearchSuffixes = [ '/index.node', ]; +/** + * Convert a module specifier to a `file://` URL string (maybe). + * + * Normalizes a runtime-computed module specifier to a `file://` URL string when + * the specifier names a filesystem location. Returns `undefined` for specifiers + * that need no such handling (relative paths, bare package names) and for + * locations that cannot be recognized with the available powers. + * + * A leading `/` is unambiguously absolute and costs no powers to recognize. + * Every other absolute form — notably a Windows path such as `C:\dir\mod.js` — + * is syntactically indistinguishable from a bare package specifier, so + * recognizing one requires `isAbsolute`. + * + * Conversion prefers `pathToFileURL`. That is the only correct option for a + * Windows path, since the URL parser reads the drive letter of `C:\dir\mod.js` + * as a scheme and yields `c:\dir\mod.js`, and it is also the only one that + * percent-encodes a `#` or `?` occurring in a filename. Lacking the power we + * fall back to URL arithmetic, which resolves a leading slash against the root + * correctly and so keeps POSIX hosts working with no powers at all. + * + * Where a needed power is absent this returns `undefined`, leaving the + * specifier to fall through to the exit-module hook. + * + * @param {string} moduleSpecifier + * @param {ReadPowers} readPowers + * @returns {string | undefined} A `file:` URL href, or `undefined` + */ +const asFileUrl = (moduleSpecifier, readPowers) => { + if (moduleSpecifier.startsWith('file://')) { + return new URL(moduleSpecifier).href; + } + const { isAbsolute = specifier => specifier.startsWith('/'), pathToFileURL } = + readPowers; + if (!isAbsolute(moduleSpecifier)) { + return undefined; + } + if (typeof pathToFileURL === 'function') { + return pathToFileURL(moduleSpecifier).href; + } + // this conditional will always fail on Windows paths. + // pathToFileURL will URL-encode fragments and querystrings; this will not. + return moduleSpecifier.startsWith('/') + ? new URL(moduleSpecifier, 'file:').href + : undefined; +}; + /** * Returns `true` if `absoluteModuleSpecifier` is within the path `compartmentLocation`. * @param {string} absoluteModuleSpecifier Absolute path to module specifier @@ -145,7 +193,7 @@ const relativeSpecifier = (absoluteModuleSpecifier, compartmentLocation) => { * Given a module specifier which is an absolute path, attempt to match it with * an existing compartment; return a {@link RedirectStaticModuleInterface} if found. * - * @throws If we determine `absoluteModuleSpecifier` is unknown + * @throws {Error} If we determine `absoluteModuleSpecifier` is unknown * @param {FindRedirectParams} params Parameters * @returns {RedirectStaticModuleInterface|undefined} A redirect or nothing */ @@ -626,7 +674,9 @@ export const makeImportHookMaker = ( packageLocation = resolveLocation(packageLocation, baseLocation); const packageSources = sources[packageLocation] || create(null); sources[packageLocation] = packageSources; - const compartmentDescriptor = compartmentDescriptors[packageLocation] || {}; + const compartmentDescriptor = /** @type {PackageCompartmentDescriptor} */ ( + compartmentDescriptors[packageLocation] || create(null) + ); const { modules: moduleDescriptors = create(null) } = compartmentDescriptor; compartmentDescriptor.modules = moduleDescriptors; @@ -638,9 +688,11 @@ export const makeImportHookMaker = ( /** @type {ImportHook} */ const importHook = async moduleSpecifier => { + const unpackedPowers = unpackReadPowers(readPowers); + const { maybeRead } = unpackedPowers; + compartmentDescriptor.retained = true; - // for lint rule await null; // All importHook errors must be deferred if coming from loading dependencies @@ -648,6 +700,29 @@ export const makeImportHookMaker = ( try { // per-module: + // Handle runtime-computed `file://` URLs and absolute paths, which + // `resolveDynamic` in `resolveHook` passes through verbatim. We + // normalize them to a `file:` URL and then either return a redirect + // (within-compartment) or delegate to `findRedirect` (cross-package). + const locationUrl = asFileUrl(moduleSpecifier, unpackedPowers); + if (locationUrl !== undefined) { + if (isLocationWithinCompartment(locationUrl, packageLocation)) { + return { + specifier: relativeSpecifier(locationUrl, packageLocation), + compartment: compartments[packageLocation], + }; + } + const record = findRedirect({ + compartmentDescriptor, + compartmentDescriptors, + compartments, + absoluteModuleSpecifier: locationUrl, + }); + if (record !== undefined) { + return record; + } + } + // In Node.js, an absolute specifier always indicates a built-in or // third-party dependency. // The `moduleMapHook` captures all third-party dependencies, unless @@ -702,8 +777,6 @@ export const makeImportHookMaker = ( ); } - const { maybeRead } = unpackReadPowers(readPowers); - const candidates = nominateCandidates(moduleSpecifier, searchSuffixes); const record = await asyncTrampoline( @@ -869,18 +942,23 @@ export function makeImportNowHookMaker( compartmentDescriptor.modules = moduleDescriptors; - const { maybeReadNow, isAbsolute } = readPowers; + const { maybeReadNow } = readPowers; /** @type {ImportNowHook} */ const importNowHook = moduleSpecifier => { try { - // many dynamically-required specifiers will be absolute paths owing to use of `require.resolve()` and `path.resolve()` - if (isAbsolute(moduleSpecifier)) { + // many dynamically-required specifiers will be absolute paths owing to + // use of `require.resolve()` and `path.resolve()`. Normalizing to a + // `file:` URL up front is what lets the comparisons below hold: every + // compartment location is a URL, so an unconverted path could never + // match one. + const locationUrl = asFileUrl(moduleSpecifier, readPowers); + if (locationUrl !== undefined) { const record = findRedirect({ compartmentDescriptor, compartmentDescriptors, compartments, - absoluteModuleSpecifier: moduleSpecifier, + absoluteModuleSpecifier: locationUrl, }); if (record) { return record; @@ -889,16 +967,8 @@ export function makeImportNowHookMaker( // if and only if the module specifier is within the compartment can we // make it a relative specifier. the following conditional avoids a try/catch // since `relativeSpecifier` will throw if this condition is not met - if ( - isLocationWithinCompartment( - moduleSpecifier, - compartmentDescriptor.location, - ) - ) { - moduleSpecifier = relativeSpecifier( - moduleSpecifier, - compartmentDescriptor.location, - ); + if (isLocationWithinCompartment(locationUrl, packageLocation)) { + moduleSpecifier = relativeSpecifier(locationUrl, packageLocation); } } else if ( moduleSpecifier !== '.' && diff --git a/packages/compartment-mapper/src/link.js b/packages/compartment-mapper/src/link.js index a880bf0e73..0e74088019 100644 --- a/packages/compartment-mapper/src/link.js +++ b/packages/compartment-mapper/src/link.js @@ -52,6 +52,34 @@ const { hasOwnProperty } = Object.prototype; const { apply } = Reflect; const { allSettled } = Promise; +/** + * A resolve hook that passes `file://` URLs and leading-slash (absolute) + * paths through untouched, delegating everything else to `resolveFallback`. + * + * This is the default `resolveHook` for live-import paths. It ensures that + * runtime-computed dynamic `import()` specifiers of the form + * `file:///abs/path/mod.js` or `/abs/path/mod.js` survive the + * `resolveHook` stage and reach `importHook` intact, where they are resolved + * against the compartment map. `resolveFallback` would otherwise throw on + * the latter and mangle the former. + * + * Windows absolute paths such as `C:\dir\mod.js` need no case of their own: + * `resolveFallback` already returns them unchanged, since it recognizes only + * `.` and `..` segments as meaningful. They are likewise resolved in + * `importHook`, which unlike this hook has the `isAbsolute` power needed to + * distinguish them from bare package specifiers. + * + * @param {string} spec + * @param {string} referrer + * @returns {string} + */ +const resolveDynamic = (spec, referrer) => { + if (spec.startsWith('file://') || spec.startsWith('/')) { + return spec; + } + return resolveFallback(spec, referrer); +}; + /** * @template T * @type {(iterable: Iterable>) => Promise>>} @@ -358,7 +386,7 @@ export const link = ( options, ) => { const { - resolve = resolveFallback, + resolve = resolveDynamic, makeImportHook, makeImportNowHook = impossibleImportNowHookMaker, parserForLanguage: parserForLanguageOption = {}, diff --git a/packages/compartment-mapper/test/dynamic-import-esm.test.js b/packages/compartment-mapper/test/dynamic-import-esm.test.js index daceae8365..69ee1bd883 100644 --- a/packages/compartment-mapper/test/dynamic-import-esm.test.js +++ b/packages/compartment-mapper/test/dynamic-import-esm.test.js @@ -1,13 +1,28 @@ import 'ses'; import test from 'ava'; +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { importLocation } from '../src/import.js'; +import { makeReadPowers, makeReadNowPowers } from '../src/node-powers.js'; import { scaffold } from './scaffold.js'; +/** + * @import {ReadNowPowers, ReadPowers} from '../src/types.js'; + */ + const fixture = new URL( 'fixtures-dynamic-import-esm/node_modules/app/index.js', import.meta.url, ).toString(); +// readPowers without sync powers — enough for ESM-only scenarios +const readPowers = makeReadPowers({ fs, url }); +// these are for testing dynamic imports of absolute paths and file: URLs +const readNowPowers = makeReadNowPowers({ fs, url, path }); + scaffold( 'fixtures-dynamic-import-esm', test, @@ -44,3 +59,248 @@ scaffold( }, }, ); + +/** + * Import the app fixture and call getDynamic with the given specifier. + * @param {ReadPowers | ReadNowPowers} powers + * @param {string} specifier + * @returns {Promise} + */ +const runDynamic = async (powers, specifier) => { + const { namespace } = await importLocation(powers, fixture, {}); + return /** @type {any} */ (namespace).getDynamic(specifier); +}; + +test('dynamic import: relative specifier ./foo.js', async t => { + const result = await runDynamic(readPowers, './foo.js'); + t.is(result, 'foo'); +}); + +test('dynamic import: relative nested specifier ./lib/bar.js', async t => { + // lib/bar.js is within the app compartment; referrer is app/index.js. + const result = await runDynamic(readPowers, './lib/bar.js'); + t.is(result, 'bar'); +}); + +test('dynamic import: bare package name dep', async t => { + const result = await runDynamic(readPowers, 'dep'); + t.is(result, 'dep-default'); +}); + +test('dynamic import: bare package subpath dep/other.js', async t => { + const result = await runDynamic(readPowers, 'dep/other.js'); + t.is(result, 'dep-other'); +}); + +test('dynamic import: undeclared bare package throws', async t => { + // 'not-a-dep' is not in app's dependency graph; must not resolve. + await t.throwsAsync(() => runDynamic(readPowers, 'not-a-dep')); +}); + +test('dynamic import: absolute path within compartment', async t => { + const appDir = readNowPowers.fileURLToPath( + new URL('fixtures-dynamic-import-esm/node_modules/app/', import.meta.url) + .href, + ); + const absoluteFoo = path.join(appDir, 'foo.js'); + const result = await runDynamic(readNowPowers, absoluteFoo); + t.is(result, 'foo'); +}); + +test('dynamic import: absolute path cross-compartment without policy throws Could not import', async t => { + // findRedirect throws "Could not import module" when there is no policy + // permitting the cross-compartment access. + const depDir = readNowPowers.fileURLToPath( + new URL('fixtures-dynamic-import-esm/node_modules/dep/', import.meta.url) + .href, + ); + const absoluteDep = path.join(depDir, 'index.js'); + await t.throwsAsync(() => runDynamic(readNowPowers, absoluteDep), { + message: /Could not import/, + }); +}); + +test('dynamic import: file: URL within compartment', async t => { + // file:// URLs need no sync powers — packageLocation is itself a file: URL + // and all resolution is pure URL arithmetic. + const fileUrl = new URL( + 'fixtures-dynamic-import-esm/node_modules/app/foo.js', + import.meta.url, + ).href; + const result = await runDynamic(readPowers, fileUrl); + t.is(result, 'foo'); +}); + +test('dynamic import: file: URL cross-compartment without policy throws Could not import', async t => { + // findRedirect throws "Could not import module" when there is no policy. + const fileUrl = new URL( + 'fixtures-dynamic-import-esm/node_modules/dep/index.js', + import.meta.url, + ).href; + await t.throwsAsync(() => runDynamic(readPowers, fileUrl), { + message: /Could not import/, + }); +}); + +test('dynamic import: file: URL and relative specifier share the same module instance', async t => { + // Both spellings should resolve to the same module record, so their + // namespaces are reference-identical. + const fileUrl = new URL( + 'fixtures-dynamic-import-esm/node_modules/app/foo.js', + import.meta.url, + ).href; + const { namespace } = await importLocation(readPowers, fixture, {}); + const ns = /** @type {any} */ (namespace); + const nsFromRelative = await ns.getDynamic('./foo.js'); + const nsFromFileUrl = await ns.getDynamic(fileUrl); + // Both return the default export value — 'foo' — confirming the same module. + t.is(nsFromRelative, 'foo'); + t.is(nsFromFileUrl, 'foo'); +}); + +test('dynamic import: absolute path and relative specifier share the same module instance', async t => { + const appDir = readNowPowers.fileURLToPath( + new URL('fixtures-dynamic-import-esm/node_modules/app/', import.meta.url) + .href, + ); + const absoluteFoo = path.join(appDir, 'foo.js'); + const { namespace } = await importLocation(readNowPowers, fixture, {}); + const ns = /** @type {any} */ (namespace); + const nsFromRelative = await ns.getDynamic('./foo.js'); + const nsFromAbsolute = await ns.getDynamic(absoluteFoo); + t.is(nsFromRelative, 'foo'); + t.is(nsFromAbsolute, 'foo'); +}); + +/** The absolute path of `foo.js` inside the app fixture compartment. */ +const absoluteFooPath = () => + path.join( + readNowPowers.fileURLToPath( + new URL('fixtures-dynamic-import-esm/node_modules/app/', import.meta.url) + .href, + ), + 'foo.js', + ); + +test('dynamic import: absolute path falls through to the exit-module handler when isAbsolute is a stub', async t => { + // Omitting `path` from makeReadPowers substitutes a stub isAbsolute that + // always answers false, so no specifier is ever recognized as a location — + // even though this powers object does supply pathToFileURL. Absolute paths + // are therefore gated on `path`, not merely on `pathToFileURL`. + const powers = makeReadPowers({ fs, url }); + t.is(powers.isAbsolute?.('/definitely/absolute.js'), false); + + await t.throwsAsync(() => runDynamic(powers, absoluteFooPath()), { + message: /Cannot find external module/, + }); +}); + +test('dynamic import: absolute POSIX path resolves without pathToFileURL', async t => { + // A leading slash resolves against the root under plain URL arithmetic, so + // the conversion needs no power. Dropping pathToFileURL leaves POSIX + // absolute paths working; only non-slash forms (Windows) require it. + const { pathToFileURL: _dropped, ...powers } = makeReadNowPowers({ + fs, + url, + path, + }); + const result = await runDynamic( + /** @type {any} */ (powers), + absoluteFooPath(), + ); + t.is(result, 'foo'); +}); + +test('dynamic import: file: URL succeeds with bare read-only readPowers (no sync powers)', async t => { + // file:// URLs require zero additional powers beyond the standard read power. + const fileUrl = new URL( + 'fixtures-dynamic-import-esm/node_modules/app/foo.js', + import.meta.url, + ).href; + const result = await runDynamic(readPowers, fileUrl); + t.is(result, 'foo'); +}); + +// A Windows path such as `C:\dir\mod.js` has no leading slash, so it is +// syntactically indistinguishable from a bare package specifier. Recognizing +// one therefore requires `isAbsolute` from readPowers, and converting it to a +// URL requires `pathToFileURL`. +// +// Node supplies neither in a Windows flavor when the host is POSIX: `node:path` +// is `path.posix` (so `isAbsolute('C:\\x')` is false), and while +// `url.pathToFileURL` accepts a `windows` option, the default resolves such a +// path against cwd. The tests below inject win32-flavored powers so the +// Windows branch can be exercised on any host. + +const WINDOWS_ABSOLUTE_SPECIFIER = 'C:\\Users\\me\\app\\foo.js'; + +/** + * A win32-flavored `pathToFileURL`. Node's own conversion does the work; the + * `windows` option is not reflected in the `ReadPowers` signature, so this + * wrapper both applies it and restores the expected arity. + * + * The resulting drive letter is then dropped, so the fake drive `C:` behaves + * as though mounted at the POSIX root. That fiction lets a test address the + * real fixture directory by its Windows spelling. + * + * @param {string} filePath + * @returns {URL} + */ +const win32PathToFileURL = filePath => + new URL( + url + .pathToFileURL(filePath, { windows: true }) + .href.replace(/^file:\/\/\/[A-Za-z]:\//, 'file:///'), + ); + +/** + * Spells a POSIX path the way a Windows host would, as the inverse of the + * drive-letter fiction above. + * + * @param {string} posixPath + * @returns {string} + */ +const asWindowsPath = posixPath => `C:${posixPath.replace(/\//g, '\\')}`; + +/** readPowers presenting a Windows-like platform to importHook. */ +const win32Powers = { + ...makeReadNowPowers({ fs, url, path }), + isAbsolute: path.win32.isAbsolute, + pathToFileURL: win32PathToFileURL, +}; + +test('dynamic import: Windows absolute path within compartment resolves', async t => { + const appDir = readNowPowers.fileURLToPath( + new URL('fixtures-dynamic-import-esm/node_modules/app/', import.meta.url) + .href, + ); + const windowsFoo = asWindowsPath(path.join(appDir, 'foo.js')); + t.regex(windowsFoo, /^C:\\/); + + const result = await runDynamic(win32Powers, windowsFoo); + t.is(result, 'foo'); +}); + +test('dynamic import: Windows absolute path is recognized as a location, not a package name', async t => { + // A Windows path pointing outside any compartment reaches findRedirect, + // which walks to the filesystem root and reports an unknown module. The + // distinct error proves the specifier was treated as a location rather than + // falling through to the exit-module branch as a bare package name. + await t.throwsAsync( + () => runDynamic(win32Powers, WINDOWS_ABSOLUTE_SPECIFIER), + { message: /Could not import unknown module/ }, + ); +}); + +test('dynamic import: Windows absolute falls through to exit module hook without a Windows-aware isAbsolute', async t => { + // On a POSIX host node:path is path.posix, which does not recognize drive + // letters, so the specifier is indistinguishable from a package name and + // falls through to the exit module hook. + t.false(path.isAbsolute(WINDOWS_ABSOLUTE_SPECIFIER)); + t.true(path.win32.isAbsolute(WINDOWS_ABSOLUTE_SPECIFIER)); + + await t.throwsAsync( + () => runDynamic(readNowPowers, WINDOWS_ABSOLUTE_SPECIFIER), + { message: /Cannot find external module/ }, + ); +}); diff --git a/packages/compartment-mapper/test/dynamic-require.test.js b/packages/compartment-mapper/test/dynamic-require.test.js index 1e3e86f757..9c4cd0209f 100644 --- a/packages/compartment-mapper/test/dynamic-require.test.js +++ b/packages/compartment-mapper/test/dynamic-require.test.js @@ -341,6 +341,78 @@ test('dynamic require using absolute path avoids exitModuleImportNowHook', async t.is(importNowHookCallCount, 0); }); +// The same fixture as above, but with readPowers that present a Windows +// platform. `sprunt` builds its specifier from `__dirname`, so a Windows +// `fileURLToPath` is enough to make the specifier a Windows absolute path and +// drive the require through the win32 branch of specifier resolution. +// +// Only the powers that describe the platform are swapped. Reading is +// unaffected: makeReadNowPowers captures its own fileURLToPath internally, so +// the fixtures are still read from the real POSIX filesystem. +test('dynamic require using Windows absolute path avoids exitModuleImportNowHook', async t => { + t.plan(2); + const fixture = new URL( + 'fixtures-dynamic/node_modules/absolute-app/index.js', + import.meta.url, + ).href; + + // The fiction is that drive C: is mounted at the POSIX root, so these two + // conversions remain inverses of one another. + /** @param {string | URL} fileUrl */ + const win32FileURLToPath = fileUrl => + `C:${url.fileURLToPath(fileUrl).replace(/\//g, '\\')}`; + /** @param {string} filePath */ + const win32PathToFileURL = filePath => + new URL( + url + .pathToFileURL(filePath, { windows: true }) + .href.replace(/^file:\/\/\/[A-Za-z]:\//, 'file:///'), + ); + + const win32Powers = { + ...readPowers, + fileURLToPath: win32FileURLToPath, + isAbsolute: path.win32.isAbsolute, + pathToFileURL: win32PathToFileURL, + }; + + let importNowHookCallCount = 0; + /** @type {ExitModuleImportNowHook} */ + const importNowHook = () => { + importNowHookCallCount += 1; + throw new Error('should not be called'); + }; + + /** @type {Policy} */ + const policy = { + entry: { + packages: WILDCARD_POLICY_VALUE, + globals: WILDCARD_POLICY_VALUE, + builtins: WILDCARD_POLICY_VALUE, + }, + resources: { + 'sprunt>node-tammy-build': { + packages: { + sprunt: true, + }, + }, + sprunt: { + packages: { + 'sprunt>node-tammy-build': true, + }, + }, + }, + }; + + const { namespace } = await importLocation(win32Powers, fixture, { + policy, + importNowHook, + }); + + t.deepEqual({ default: { isOk: 1 }, isOk: 1 }, { ...namespace }); + t.is(importNowHookCallCount, 0); +}); + test('dynamic require of disallowed package', async t => { const fixture = new URL( 'fixtures-dynamic/node_modules/broken-app/index.js', diff --git a/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/index.js b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/index.js index 98b1da2273..bb90b4e3e6 100644 --- a/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/index.js +++ b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/index.js @@ -2,3 +2,10 @@ export const getFoo = async () => { const foo = await import('./foo.js'); return foo.default; }; + +// Specifier is opaque to the parser at bundle/archive time — used by +// runtime-computed-specifier tests. +export const getDynamic = async specifier => { + const ns = await import(specifier); + return ns.default; +}; diff --git a/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/lib/bar.js b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/lib/bar.js new file mode 100644 index 0000000000..d9a5cffdad --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/lib/bar.js @@ -0,0 +1 @@ +export default 'bar'; diff --git a/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/package.json index 010d106db8..a4c5cdd4f6 100644 --- a/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/package.json +++ b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/app/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "type": "module", "main": "./index.js", + "dependencies": { + "dep": "*" + }, "scripts": { "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" } diff --git a/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/index.js b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/index.js new file mode 100644 index 0000000000..b32b90868f --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/index.js @@ -0,0 +1 @@ +export default 'dep-default'; diff --git a/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/other.js b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/other.js new file mode 100644 index 0000000000..7691f0a875 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/other.js @@ -0,0 +1 @@ +export default 'dep-other'; diff --git a/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/package.json b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/package.json new file mode 100644 index 0000000000..d41539a4f5 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-dynamic-import-esm/node_modules/dep/package.json @@ -0,0 +1,9 @@ +{ + "name": "dep", + "version": "1.0.0", + "type": "module", + "main": "./index.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +}