Skip to content

Commit 3319469

Browse files
authored
feat(compartment-mapper)!: new hooks & type overhaul (#2988)
## Description This introduces a set of new hooks to user-visible functions and overhauls many types. ### Description of changes to Types - The types around `CompartmentMapDescriptor` have been vastly expanded to reflect the different "flavors" of `CompartmentMapDescriptor`, `CompartmentDescriptor`, `ModuleDescriptor` (now `ModuleDescriptorConfiguration` to differentiate it from `ses`' `ModuleDescriptor`), and the formatting of the compartment names (keys). - `CompartmentDescriptor.label` is now a canonical name. - `CompartmentDescriptor.path` is removed - `CompartmentDescriptor.compartments` is removed - Added many type guards for validation of these differing types - Validation of different types of compartment map - Stricter validation - Validation of all types of compartment descriptors, module descriptor configurations, etc. ### New Default Behavior in `mapNodeModules()` Currently, `mapNodeModules()` avoids adding a `ModuleDescriptor` (`ModuleDescriptorConfiguration`) to a nascent `CompartmentDescriptor`'s `modules` prop if policy disallows it. This PR changes the behavior to consider policy & excise the _dependency_ from the `Node` itself _before_ the (dependency) `Graph` is translated into a `CompartmentDescriptor`. This has implications around what sort of errors are thrown when Compartment A attempts to load a Compartment B to which it has no access (see note about policy tests below). ### Other Notes - Since `CompartmentDescriptor.compartments` has been removed, that means implicitly allowing dynamic requires of parent/ancestor Compartments from a given Compartment via absolute path _is no longer supported_. That's fine, since: - This should be explicit in policy _anyway_ - `@lavamoat/node` was the only user of this feature and no longer needs it - The entry compartment now has a canonical name of `$root$`. It _is_ valid within `PackagePolicy`; i.e. some other package can be allowed to access the `$root$` compartment. - Many tests in `test/policy.test.ts` needed to change because of how `mapNodeModules`' `packageDependencies` hook works. Instead of rejecting module descriptors (`ModuleDescriptorConfiguration`s) based on policy, we reject entire _dependencies_ before they can be "digested" into module descriptors. This results in `ScopeDescriptor`s not being populated, in addition to `ModuleDescriptorConfiguration`s, and causes downstream effects. Different exceptions are thrown at different times, but the _intent_ of these tests doesn't deviate from "ensure this naughty behavior is not allowed." - **This PR does _not_ change the archive format.** * * * This commit introduces a comprehensive universal hook system for `@endo/compartment-mapper`, enabling extensible customization of module mapping, bundling, and policy enforcement. BREAKING CHANGES: `CompartmentDescriptor` objects no longer have a `path` property and the `label` property is now a canonical name. Types have changed dramatically. Enhanced validation of `CompartmentMapDescriptor` objects. ## New Options ### `mapNodeModules()` - `packageDataHook`: Called once before `translateGraph` with data about all packages found while crawling `node_modules`. Receives a read-only `Map<CanonicalName, PackageData>` where each entry contains: - `name`: Package name - `packageDescriptor`: The package.json contents - `location`: File URL to the package - `canonicalName`: The canonical name used in policy - `packageDependenciesHook`: Called for each package's dependencies during graph translation, allowing dynamic modification of the dependency graph. Can add or remove dependencies from packages based on policy or other criteria. - `unknownCanonicalNameHook`: Called when policy references canonical names that don't exist in the dependency graph, with optional suggestions for typos/similar names. ### `makeImportHookMaker()` - `moduleSourceHook()`: Called when module source objects are created (either from local files containing `bytes`, exit modules, or "error-type" sources) ### `captureFromMap()` - `packageConnectionsHook`: Called during digest; surfaces "connections" between compartment descriptors ## Type System Overhaul (`src/types/`) ### Major Breaking Type Changes in `compartment-map-schema.ts`: #### CompartmentDescriptor Interface Restructure: - **BREAKING**: Removed `path` property - compartment descriptors no longer track dependency paths - **BREAKING**: `label` property type changed from `string` to `CanonicalName<U>` - labels are now type-safe canonical names - **BREAKING**: Made `CompartmentDescriptor` generic with `<T extends ModuleDescriptorConfiguration, U extends string>` for better type safety - Added `location: string` as a required property for all compartment descriptors - Added optional `sourceDirname` as found in the sources #### CompartmentMapDescriptor Genericization (Is That A Word? No): - **BREAKING**: `CompartmentMapDescriptor` is now generic: `CompartmentMapDescriptor<T, Name, EntryName>` - **BREAKING**: `compartments` property changed from `Record<string, CompartmentDescriptor>` to `CompartmentDescriptors<T, Name>` - **BREAKING**: `entry` property type changed from `EntryDescriptor` to `EntryDescriptor<EntryName>` - New specialized types: `PackageCompartmentMapDescriptor`, `FileCompartmentMapDescriptor`, and `DigestedCompartmentMapDescriptor` #### Enhanced Module Descriptor System: - `ModuleDescriptor` is now `ModuleDescriptorConfiguration` to differentiate between it & the `ModuleDescriptor` from `ses` - Added `ModuleDescriptorConfigurationCreator` enum tracking module creation source (`'link' | 'transform' | 'import-hook' | 'digest' | 'node-modules'`) - Enhanced `BaseModuleDescriptorConfiguration` with `__createdBy` property for debugging - Added `ErrorModuleDescriptorConfiguration` for deferred error handling - Improved type discrimination with `ModuleDescriptorConfigurationKindToType` utility type ### New Type Infrastructure: #### `canonical-name.ts` - Canonical Name Type System: - **NEW**: Type-level canonical name validation using template literal types - `CanonicalName<S>` - validates npm package name chains separated by '>' (e.g., "foo>bar", "@scope/pkg>dep") - `ScopedPackageName` and `UnscopedPackageName` for npm package name validation - `SplitOnGt<S>` and `AllValidPackageNames<Parts>` for compile-time canonical name parsing - `IsCanonicalName<S>` predicate type for conditional type logic - ### Enhanced Type Safety Features: #### Compartment Descriptor Validation: - `DigestedCompartmentDescriptor` - restricted descriptor for archived compartment maps - Properties marked as `never` for digested descriptors: `path`, `retained`, `scopes`, `parsers`, `types`, `__createdBy`, `sourceDirname` - `CompartmentDescriptorWithPolicy<T>` - enforces policy presence where required #### Module Configuration Type Safety: - `ModuleDescriptorConfigurationKind` union for module type discrimination - Type-safe module configuration creators with `__createdBy` tracking #### Policy Integration: - Enhanced policy types in `PackageCompartmentDescriptor` with canonical name constraints - `LiteralUnion` usage for special canonical names (`ATTENUATORS_COMPARTMENT`, `ENTRY_COMPARTMENT`) - Policy-aware compartment descriptor types with enhanced validation ### Type System Utilities: #### `typescript.ts` Enhancements: - Enhanced `LiteralUnion<L, B>` for better literal type preservation - Type utilities supporting the new generic compartment map architecture - Moved `Simplify` from tests here (because it's useful dammit) ## Enhanced Policy Validation ### Policy Validation: - Unknown canonical name detection with suggestion system - Cross-reference policy resources against actual compartment contents - Detailed error reporting with path information for policy issues - Hook integration for custom policy validation logic
2 parents 2412537 + ccf0ae1 commit 3319469

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+6928
-1271
lines changed

packages/compartment-mapper/NEWS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@ User-visible changes to `@endo/compartment-mapper`:
22

33
# Next release
44

5+
- **Breaking:** `CompartmentMapDescriptor` no longer has a `path` property.
6+
- **Breaking:** `CompartmentMapDescriptor`'s `label` property is now a
7+
_canonical name_ (a string of one or more npm package names separated by `>`).
8+
- **Breaking:** The `CompartmentMapDescriptor` returned by `captureFromMap()`
9+
now uses canonical names as the keys in its `compartments` property.
10+
- Breaking types: `CompartmentMapDescriptor`, `CompartmentDescriptor`,
11+
`ModuleConfiguration` (renamed from `ModuleDescriptor`) and `ModuleSource`
12+
have all been narrowed into discrete subtypes.
13+
- `captureFromMap()`, `loadLocation()` and `importLocation()` now accept a
14+
`moduleSourceHook` option. This hook is called when processing each module
15+
source, receiving the module source data (location, language, bytes, or error
16+
information) and the canonical name of the containing package.
17+
- `captureFromMap()` now accepts a `packageConnectionsHook` option. This hook is
18+
called for each retained compartment with its canonical name and the set of
19+
canonical names of compartments it links to (its connections). Useful for
20+
analyzing or visualizing the dependency graph.
21+
- `mapNodeModules()`, `loadLocation()`, `importLocation()`, `makeScript()`,
22+
`makeFunctor()`, and `writeScript()` now accept the following hook options:
23+
- `unknownCanonicalNameHook`: Called for each canonical name mentioned in
24+
policy but not found in the compartment map. Useful for detecting policy
25+
misconfigurations.
26+
- `packageDependenciesHook`: Called for each package with its set of
27+
dependencies. Can return partial updates to modify the dependencies,
28+
enabling dependency filtering or injection based on policy.
29+
- `packageDataHook`: Called once with data about all packages found while
30+
crawling `node_modules`, just prior to creation of a compartment map.
531
- When dynamic requires are enabled via configuration, execution now takes
632
policy into consideration when no other relationship (for example, a
733
dependent/dependee relationship) between two Compartments exists. When policy

packages/compartment-mapper/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"ses": "workspace:^"
6969
},
7070
"devDependencies": {
71+
"@endo/env-options": "workspace:^",
7172
"ava": "catalog:dev",
7273
"c8": "catalog:dev",
7374
"eslint": "catalog:dev",

packages/compartment-mapper/src/archive-lite.js

Lines changed: 78 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
/* Provides functions to create an archive (zip file with a
1+
/**
2+
* Provides functions to create an archive (zip file with a
23
* compartment-map.json) from a partially completed compartment map (it must
34
* mention all packages/compartments as well as inter-compartment references
45
* but does not contain an entry for every module reachable from its entry
@@ -26,6 +27,8 @@
2627
* In fruition of https://github.com/endojs/endo/issues/400, we will be able to
2728
* use original source archives on XS and Node.js, but not on the web until the
2829
* web platform makes further progress on virtual module loaers.
30+
*
31+
* @module
2932
*/
3033

3134
/* eslint no-shadow: 0 */
@@ -36,8 +39,10 @@
3639
* ArchiveResult,
3740
* ArchiveWriter,
3841
* CaptureSourceLocationHook,
39-
* CompartmentMapDescriptor,
42+
* CompartmentsRenameFn,
43+
* FileUrlString,
4044
* HashPowers,
45+
* PackageCompartmentMapDescriptor,
4146
* ReadFn,
4247
* ReadPowers,
4348
* Sources,
@@ -55,19 +60,12 @@ import {
5560
import { unpackReadPowers } from './powers.js';
5661
import { detectAttenuators } from './policy.js';
5762
import { digestCompartmentMap } from './digest.js';
63+
import { stringCompare } from './compartment-map.js';
64+
import { ATTENUATORS_COMPARTMENT } from './policy-format.js';
5865

5966
const textEncoder = new TextEncoder();
6067

61-
const { assign, create, freeze } = Object;
62-
63-
/**
64-
* @param {string} rel - a relative URL
65-
* @param {string} abs - a fully qualified URL
66-
* @returns {string}
67-
*/
68-
const resolveLocation = (rel, abs) => new URL(rel, abs).toString();
69-
70-
const { keys } = Object;
68+
const { assign, create, freeze, keys } = Object;
7169

7270
/**
7371
* @param {ArchiveWriter} archive
@@ -77,12 +75,10 @@ const addSourcesToArchive = async (archive, sources) => {
7775
await null;
7876
for (const compartment of keys(sources).sort()) {
7977
const modules = sources[compartment];
80-
const compartmentLocation = resolveLocation(`${compartment}/`, 'file:///');
8178
for (const specifier of keys(modules).sort()) {
82-
const { bytes, location } = modules[specifier];
83-
if (location !== undefined) {
84-
const moduleLocation = resolveLocation(location, compartmentLocation);
85-
const path = new URL(moduleLocation).pathname.slice(1); // elide initial "/"
79+
if ('location' in modules[specifier]) {
80+
const { bytes, location } = modules[specifier];
81+
const path = `${compartment}/${location}`;
8682
if (bytes !== undefined) {
8783
// eslint-disable-next-line no-await-in-loop
8884
await archive.write(path, bytes);
@@ -100,27 +96,78 @@ const captureSourceLocations = async (sources, captureSourceLocation) => {
10096
for (const compartmentName of keys(sources).sort()) {
10197
const modules = sources[compartmentName];
10298
for (const moduleSpecifier of keys(modules).sort()) {
103-
const { sourceLocation } = modules[moduleSpecifier];
104-
if (sourceLocation !== undefined) {
99+
if ('sourceLocation' in modules[moduleSpecifier]) {
100+
const { sourceLocation } = modules[moduleSpecifier];
105101
captureSourceLocation(compartmentName, moduleSpecifier, sourceLocation);
106102
}
107103
}
108104
}
109105
};
110106

111107
/**
112-
* @param {CompartmentMapDescriptor} compartmentMap
108+
* @param {PackageCompartmentMapDescriptor} compartmentMap
113109
* @param {Sources} sources
114110
* @returns {ArchiveResult}
115111
*/
116112
export const makeArchiveCompartmentMap = (compartmentMap, sources) => {
113+
/** @type {CompartmentsRenameFn<FileUrlString, string>} */
114+
const renameCompartments = compartments => {
115+
/** @type {Record<FileUrlString, string>} */
116+
const compartmentRenames = create(null);
117+
118+
/**
119+
* Get the new name of format `packageName-v${version}` compartments (except
120+
* for the attenuators compartment)
121+
* @param {string} name
122+
* @param {string} version
123+
* @returns {string}
124+
*/
125+
const getCompartmentName = (name, version) => {
126+
const compartment = compartments[name];
127+
return ATTENUATORS_COMPARTMENT === compartment.name
128+
? compartment.name
129+
: `${compartment.name}-v${version}`;
130+
};
131+
132+
// The sort below combines two comparators to avoid depending on sort
133+
// stability, which became standard as recently as 2019.
134+
// If that date seems quaint, please accept my regards from the distant past.
135+
// We are very proud of you.
136+
const compartmentsByLabel =
137+
/** @type {Array<{name: FileUrlString, packageName: string, compartmentName: string}>} */ (
138+
Object.entries(compartments)
139+
.map(([name, compartment]) => ({
140+
name,
141+
packageName: compartments[name].name,
142+
compartmentName: getCompartmentName(name, compartment.version),
143+
}))
144+
.sort((a, b) => stringCompare(a.compartmentName, b.compartmentName))
145+
);
146+
147+
/** @type {string|undefined} */
148+
let prev;
149+
let index = 1;
150+
for (const { name, packageName, compartmentName } of compartmentsByLabel) {
151+
if (packageName === prev) {
152+
compartmentRenames[name] = `${compartmentName}-n${index}`; // Added numeric suffix for duplicates
153+
index += 1;
154+
} else {
155+
compartmentRenames[name] = compartmentName;
156+
prev = packageName;
157+
index = 1;
158+
}
159+
}
160+
return compartmentRenames;
161+
};
162+
117163
const {
118164
compartmentMap: archiveCompartmentMap,
119165
sources: archiveSources,
120166
oldToNewCompartmentNames,
121167
newToOldCompartmentNames,
122168
compartmentRenames,
123-
} = digestCompartmentMap(compartmentMap, sources);
169+
} = digestCompartmentMap(compartmentMap, sources, { renameCompartments });
170+
124171
return {
125172
archiveCompartmentMap,
126173
archiveSources,
@@ -130,9 +177,11 @@ export const makeArchiveCompartmentMap = (compartmentMap, sources) => {
130177
};
131178
};
132179

180+
const noop = () => {};
181+
133182
/**
134183
* @param {ReadFn | ReadPowers} powers
135-
* @param {CompartmentMapDescriptor} compartmentMap
184+
* @param {PackageCompartmentMapDescriptor} compartmentMap
136185
* @param {ArchiveLiteOptions} [options]
137186
* @returns {Promise<{sources: Sources, compartmentMapBytes: Uint8Array, sha512?: string}>}
138187
*/
@@ -146,6 +195,7 @@ const digestFromMap = async (powers, compartmentMap, options = {}) => {
146195
policy = undefined,
147196
sourceMapHook = undefined,
148197
parserForLanguage: parserForLanguageOption = {},
198+
log: _log = noop,
149199
} = options;
150200

151201
const parserForLanguage = freeze(
@@ -179,6 +229,7 @@ const digestFromMap = async (powers, compartmentMap, options = {}) => {
179229
importHook: consolidatedExitModuleImportHook,
180230
sourceMapHook,
181231
});
232+
182233
// Induce importHook to record all the necessary modules to import the given module specifier.
183234
const { compartment, attenuatorsCompartment } = link(compartmentMap, {
184235
resolve,
@@ -229,7 +280,7 @@ const digestFromMap = async (powers, compartmentMap, options = {}) => {
229280

230281
/**
231282
* @param {ReadFn | ReadPowers} powers
232-
* @param {CompartmentMapDescriptor} compartmentMap
283+
* @param {PackageCompartmentMapDescriptor} compartmentMap
233284
* @param {ArchiveLiteOptions} [options]
234285
* @returns {Promise<{bytes: Uint8Array, sha512?: string}>}
235286
*/
@@ -254,7 +305,7 @@ export const makeAndHashArchiveFromMap = async (
254305

255306
/**
256307
* @param {ReadFn | ReadPowers} powers
257-
* @param {CompartmentMapDescriptor} compartmentMap
308+
* @param {PackageCompartmentMapDescriptor} compartmentMap
258309
* @param {ArchiveLiteOptions} [options]
259310
* @returns {Promise<Uint8Array>}
260311
*/
@@ -269,7 +320,7 @@ export const makeArchiveFromMap = async (powers, compartmentMap, options) => {
269320

270321
/**
271322
* @param {ReadFn | ReadPowers} powers
272-
* @param {CompartmentMapDescriptor} compartmentMap
323+
* @param {PackageCompartmentMapDescriptor} compartmentMap
273324
* @param {ArchiveLiteOptions} [options]
274325
* @returns {Promise<Uint8Array>}
275326
*/
@@ -284,7 +335,7 @@ export const mapFromMap = async (powers, compartmentMap, options) => {
284335

285336
/**
286337
* @param {HashPowers} powers
287-
* @param {CompartmentMapDescriptor} compartmentMap
338+
* @param {PackageCompartmentMapDescriptor} compartmentMap
288339
* @param {ArchiveLiteOptions} [options]
289340
* @returns {Promise<string>}
290341
*/
@@ -302,7 +353,7 @@ export const hashFromMap = async (powers, compartmentMap, options) => {
302353
* @param {WriteFn} write
303354
* @param {ReadFn | ReadPowers} readPowers
304355
* @param {string} archiveLocation
305-
* @param {CompartmentMapDescriptor} compartmentMap
356+
* @param {PackageCompartmentMapDescriptor} compartmentMap
306357
* @param {ArchiveLiteOptions} [options]
307358
*/
308359
export const writeArchiveFromMap = async (

packages/compartment-mapper/src/archive.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
* ReadPowers,
2525
* HashPowers,
2626
* WriteFn,
27+
* LogFn,
2728
* } from './types.js'
2829
*/
2930

@@ -169,6 +170,9 @@ export const mapLocation = async (powers, moduleLocation, options = {}) => {
169170
});
170171
};
171172

173+
/** @type {LogFn} */
174+
const noop = () => {};
175+
172176
/**
173177
* @param {HashPowers} powers
174178
* @param {string} moduleLocation
@@ -191,10 +195,12 @@ export const hashLocation = async (powers, moduleLocation, options = {}) => {
191195
workspaceLanguageForExtension,
192196
workspaceCommonjsLanguageForExtension,
193197
workspaceModuleLanguageForExtension,
198+
log = noop,
194199
...otherOptions
195200
} = assignParserForLanguage(options);
196201

197202
const compartmentMap = await mapNodeModules(powers, moduleLocation, {
203+
log,
198204
dev,
199205
strict,
200206
conditions,
@@ -212,6 +218,7 @@ export const hashLocation = async (powers, moduleLocation, options = {}) => {
212218
return hashFromMap(powers, compartmentMap, {
213219
parserForLanguage,
214220
policy,
221+
log,
215222
...otherOptions,
216223
});
217224
};

packages/compartment-mapper/src/bundle-lite.js

Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
* } from 'ses'
88
* @import {
99
* BundleOptions,
10-
* CompartmentDescriptor,
11-
* CompartmentMapDescriptor,
1210
* CompartmentSources,
11+
* PackageCompartmentDescriptors,
12+
* PackageCompartmentMapDescriptor,
1313
* MaybeReadPowers,
1414
* ReadFn,
1515
* ReadPowers,
@@ -110,6 +110,11 @@ import { defaultParserForLanguage } from './archive-parsers.js';
110110
import mjsSupport from './bundle-mjs.js';
111111
import cjsSupport from './bundle-cjs.js';
112112
import jsonSupport from './bundle-json.js';
113+
import {
114+
isErrorModuleSource,
115+
isExitModuleSource,
116+
isLocalModuleSource,
117+
} from './guards.js';
113118

114119
const { quote: q } = assert;
115120

@@ -144,7 +149,7 @@ null,
144149
* The first modules are place-holders for the modules that exit
145150
* the compartment map to the host's module system.
146151
*
147-
* @param {Record<string, CompartmentDescriptor>} compartmentDescriptors
152+
* @param {PackageCompartmentDescriptors} compartmentDescriptors
148153
* @param {Record<string, CompartmentSources>} compartmentSources
149154
* @param {string} entryCompartmentName
150155
* @param {string} entryModuleSpecifier
@@ -188,28 +193,18 @@ const sortedModules = (
188193

189194
const source = compartmentSources[compartmentName][moduleSpecifier];
190195
if (source !== undefined) {
191-
const { record, parser, deferredError, bytes, sourceDirname, exit } =
192-
source;
193-
if (exit !== undefined) {
194-
return exit;
195-
}
196-
assert(
197-
bytes !== undefined,
198-
`No bytes for ${moduleSpecifier} in ${compartmentName}`,
199-
);
200-
assert(
201-
parser !== undefined,
202-
`No parser for ${moduleSpecifier} in ${compartmentName}`,
203-
);
204-
assert(
205-
sourceDirname !== undefined,
206-
`No sourceDirname for ${moduleSpecifier} in ${compartmentName}`,
207-
);
208-
if (deferredError) {
196+
if (isErrorModuleSource(source)) {
209197
throw Error(
210-
`Cannot bundle: encountered deferredError ${deferredError}`,
198+
`Cannot bundle: encountered deferredError ${source.deferredError}`,
211199
);
212200
}
201+
if (isExitModuleSource(source)) {
202+
return source.exit;
203+
}
204+
if (!isLocalModuleSource(source)) {
205+
throw new TypeError(`Unexpected source type ${JSON.stringify(source)}`);
206+
}
207+
const { record, parser, bytes, sourceDirname } = source;
213208
if (record) {
214209
const { imports = [], reexports = [] } =
215210
/** @type {PrecompiledStaticModuleInterface} */ (record);
@@ -309,7 +304,7 @@ const getBundlerKitForModule = (module, params) => {
309304

310305
/**
311306
* @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers
312-
* @param {CompartmentMapDescriptor} compartmentMap
307+
* @param {PackageCompartmentMapDescriptor} compartmentMap
313308
* @param {BundleOptions} [options]
314309
* @returns {Promise<string>}
315310
*/
@@ -651,7 +646,7 @@ ${m.bundlerKit.getFunctor()}`,
651646

652647
/**
653648
* @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers
654-
* @param {CompartmentMapDescriptor} compartmentMap
649+
* @param {PackageCompartmentMapDescriptor} compartmentMap
655650
* @param {BundleOptions} [options]
656651
* @returns {Promise<string>}
657652
*/

0 commit comments

Comments
 (0)