Skip to content

Commit a7954e9

Browse files
authored
feat(import-bundle): Test bundle format #2719 (#2735)
Closes: #2719 ## Description This change introduces a new bundle format to simplify tests for subsystems that take a bundle that they will ultimately use `importBundle` to extract a mock module exports namespace. The new bundle format takes care to render impossible any accidental or malicious use of a test h To that end, the format captures the mock exports namespace on a symbol-named property of then bundle, so it will be elided or rejected when serialized. And, to compensate for the cryptic protocol, this change provides a tiny utility function for making test bundles from mock exports. ### Security Considerations Existing bundle importers expect only to be able to confine execution of local, serialized bundles. This change takes some care to maintain the expectation by limiting exposure to live objects in a way that’s only reachable by test code. If an adversary were able to present a test bundle, it’s not clear that this would constitute any escalation in privilege, given that an attacker would need to arrange for a live object by some other escalator. ### Scaling Considerations This change introduces a very small amount of new code to the Agoric kernel and, out of an abundance of caution, is not reached in the common case of importing the endoZipBase64 bundle format. ### Documentation Considerations The change includes relevant documentation in NEWS.md and README.md for the import-bundle package. This is a new platform API and may be relevant in the context of tutorials for testing applications on Agoric and other Endo platforms. ### Testing Considerations Includes unit tests exercising the new behavior and invariants. ### Compatibility Considerations New features, no breaking changes. ### Upgrade Considerations None.
2 parents 8f97bf9 + 17ec018 commit a7954e9

File tree

5 files changed

+153
-1
lines changed

5 files changed

+153
-1
lines changed

packages/import-bundle/NEWS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
User-visible changes to `@endo/import-bundle`:
22

3+
# Next release
4+
5+
- Adds support for `test` format bundles, which simply return a promise for an
6+
object that resembles a module exports namespace with the objects specified
7+
on the symbol-named property @exports, which is deliberately not JSON
8+
serializable or passable.
9+
310
# v1.3.0 (2024-10-10)
411

512
- Adds support for `endoScript` format bundles.

packages/import-bundle/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,28 @@ evaluated, to enforce ocap rules.
2929

3030
The source can be bundled in a variety of "formats".
3131

32+
### endoZipBase64
33+
3234
By default, `bundleSource` uses a format named `endoZipBase64`, in which the
3335
source modules and a "compartment map" are captured in a Zip file and base-64
3436
encoded.
3537
The compartment map describes how to construct a set of [Hardened
3638
JavaScript](https://hardenedjs.org) compartments and how to load and link the
3739
source modules between them.
3840

41+
### endoScript
42+
3943
The `endoScript` format captures the sources as a single JavaScript program
4044
that completes with the entry module's namespace object.
4145

46+
### getExport
47+
4248
The `getExport` format captures the sources as a single CommonJS-style string,
4349
and wrapped in a callable function that provides the `exports` and
4450
`module.exports` context to which the exports can be attached.
4551

52+
### nestedEvaluate
53+
4654
More sophisticated than `getExport` is named `nestedEvaluate`.
4755
In this mode, the source tree is converted into a table of evaluable strings,
4856
one for each original module.
@@ -59,6 +67,24 @@ Note that the `nestedEvaluate` format receives a global endowment named
5967
`require`, although it will only be called if the source tree imported one of
6068
the few modules on the `bundle-source` "external" list.
6169

70+
### test
71+
72+
The `test` format is useful for mocking a bundle locally for a test and is
73+
deliberately not serializable or passable.
74+
Use this format in tests to avoid the need to generate a bundle from source,
75+
providing instead just the exports you need returned by `importBundle`.
76+
77+
```js
78+
import { importBundle, bundleTestExports } from '@endo/import-bundle';
79+
80+
test('who tests the tests', async t => {
81+
const bundle = bundleTestExports({ a: 10 });
82+
const namespace = await importBundle(bundle);
83+
t.is(namespace.a, 10);
84+
t.is(Object.prototype.toString.call(ns), '[object Module]');
85+
});
86+
```
87+
6288
## Options
6389

6490
`importBundle()` takes an options bag and optional additional powers.

packages/import-bundle/src/index.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,59 @@ export async function importBundle(bundle, options = {}, powers = {}) {
7878
return namespace;
7979
}
8080

81+
// The 'test' format is not generated by bundleSource and is not
82+
// serializable as JSON and is not passable because copy-records cannot have symbol keys.
83+
if (moduleFormat === 'test') {
84+
const exports = bundle[Symbol.for('exports')];
85+
if (exports === undefined) {
86+
throw new Error(
87+
'Cannot import bundle with moduleFormat "test" that lacks an symbol-named property @exports and has likely been partially transported via JSON or eventual-send',
88+
);
89+
}
90+
91+
// We emulate a module exports namespace object, which has certain invariants:
92+
// Property names are only strings, so we will ignore symbol-named properties.
93+
// All properties are enumerable, so we will ignore non-enumerable properties.
94+
// All properties should be writable, but we deliberately deviate rather than
95+
// emulate the exotic behavior of standard module exports namespace objects.
96+
// The namespace object is sealed.
97+
// Because we deviate from the standard behavior, the namespace object is
98+
// frozen by implication.
99+
// We capture the value for each property now and never again consult the given exports object.
100+
return Object.seal(
101+
Object.create(
102+
null,
103+
Object.fromEntries([
104+
...Object.entries(Object.getOwnPropertyDescriptors(exports))
105+
// eslint-disable-next-line no-nested-ternary
106+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
107+
.filter(
108+
([name, descriptor]) =>
109+
typeof name === 'string' && descriptor.enumerable,
110+
)
111+
.map(([name]) => [
112+
name,
113+
{
114+
value: exports[name],
115+
writable: false,
116+
enumerable: true,
117+
configurable: false,
118+
},
119+
]),
120+
[
121+
Symbol.toStringTag,
122+
{
123+
value: 'Module',
124+
writable: false,
125+
enumerable: false,
126+
configurable: false,
127+
},
128+
],
129+
]),
130+
),
131+
);
132+
}
133+
81134
let { source } = bundle;
82135
const { sourceMap } = bundle;
83136
if (moduleFormat === 'getExport') {
@@ -124,6 +177,23 @@ export async function importBundle(bundle, options = {}, powers = {}) {
124177
}
125178
}
126179

180+
/**
181+
* A utility function for producing test bundles, which are not serializable
182+
* as JSON or passable.
183+
* @param {Record<PropertyKey, unknown>} exports
184+
*/
185+
export const bundleTestExports = exports => {
186+
const symbols = Object.getOwnPropertySymbols(exports).filter(
187+
name => name !== Symbol.toStringTag,
188+
);
189+
symbols.length > 0 &&
190+
Fail`exports must not have symbol-named properties, got: ${symbols.map(String).join(', ')}`;
191+
return {
192+
moduleFormat: 'test',
193+
[Symbol.for('exports')]: exports,
194+
};
195+
};
196+
127197
/*
128198
importBundle(bundle, { metering: { getMeter, meteringOptions } });
129199
importBundle(bundle, { transforms: [ meterTransform ], lexicals: { getMeter } });
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// A fixture for the sole purpose of verifying that a module exports namespace
2+
// is a suitable argument for bundleTestExports.
3+
export {};

packages/import-bundle/test/import-bundle.test.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import crypto from 'crypto';
77
import { makeArchive } from '@endo/compartment-mapper/archive.js';
88
import bundleSource from '@endo/bundle-source';
99
import { makeReadPowers } from '@endo/compartment-mapper/node-powers.js';
10-
import { importBundle } from '../src/index.js';
10+
import { importBundle, bundleTestExports } from '../src/index.js';
11+
12+
import * as namespace from './_namespace.js';
1113

1214
const { read } = makeReadPowers({ fs, url, crypto });
1315

@@ -188,3 +190,47 @@ test('inescapable global properties, zip base64 format', async t => {
188190
});
189191
t.is(ns.default, 42);
190192
});
193+
194+
test('test the test format', async t => {
195+
const bundle = {
196+
moduleFormat: 'test',
197+
[Symbol.for('exports')]: {
198+
z: 43,
199+
default: 42,
200+
a: 41,
201+
},
202+
};
203+
const ns = await importBundle(bundle);
204+
t.is(ns.default, 42);
205+
t.deepEqual(Object.keys(ns), ['a', 'default', 'z']);
206+
t.is(Object.prototype.toString.call(ns), '[object Module]');
207+
});
208+
209+
test('test format must not round-trip via JSON', async t => {
210+
const bundle = JSON.parse(
211+
JSON.stringify({
212+
moduleFormat: 'test',
213+
[Symbol.for('exports')]: {
214+
default: 42,
215+
},
216+
}),
217+
);
218+
await t.throwsAsync(importBundle(bundle), {
219+
message: /Cannot import bundle with moduleFormat "test" that lacks/,
220+
});
221+
});
222+
223+
test('test bundle utility should fail early for symbol keys', t => {
224+
t.throws(() =>
225+
bundleTestExports({
226+
[Symbol.for('iterator')]: 1,
227+
[Symbol.iterator]: 'a',
228+
}),
229+
);
230+
});
231+
232+
test('bundleTestExports should accept a genuine module exports namespace', t => {
233+
// Taking into account that it will have a Symbol.toStringTag.
234+
bundleTestExports(namespace);
235+
t.pass();
236+
});

0 commit comments

Comments
 (0)