Skip to content

Commit

Permalink
harden-exports rule for eslint plugin (#2369)
Browse files Browse the repository at this point in the history
Refs: Agoric/agoric-sdk#9726

## Description

A new lint rule, `harden-exports`, to support
Agoric/agoric-sdk#9726

Includes an autofixer

### Security Considerations

Could enhance security

### Scaling Considerations

n/a

### Documentation Considerations

We don't yet document provided rules:
https://endojs.github.io/endo/modules/_endo_eslint_plugin.html

I think that's okay for now. If that is requested I'd file it as a
separate issue, out of scope of this one.

### Testing Considerations

I temporarily enabled this in the **recommended** config and ran
`lint-fix` on the repo. All the changes looked correct. However
`harden()` isn't always available so I don't think we should enable it
in any of Endo's shared configs.

### Compatibility Considerations

n/a

### Upgrade Considerations

n/a
  • Loading branch information
turadg authored Jul 18, 2024
2 parents 2d5c74f + 387409c commit d6a1b13
Show file tree
Hide file tree
Showing 4 changed files with 546 additions and 4 deletions.
107 changes: 107 additions & 0 deletions packages/eslint-plugin/lib/rules/harden-exports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @fileoverview Ensure each named export is followed by a call to `harden` function
*/

'use strict';

/**
* @import {Rule} from 'eslint';
* @import * as ESTree from 'estree';
*/

/**
* ESLint rule module for ensuring each named export is followed by a call to `harden` function.
* @type {Rule.RuleModule}
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Ensure each named export is followed by a call to `harden` function',
category: 'Possible Errors',
recommended: false,
},
fixable: 'code',
schema: [],
},
/**
* Create function for the rule.
* @param {Rule.RuleContext} context - The rule context.
* @returns {Object} The visitor object.
*/
create(context) {
/** @type {Array<ESTree.ExportNamedDeclaration & Rule.NodeParentExtension>} */
let exportNodes = [];

return {
/** @param {ESTree.ExportNamedDeclaration & Rule.NodeParentExtension} node */
ExportNamedDeclaration(node) {
exportNodes.push(node);
},
'Program:exit'() {
const sourceCode = context.getSourceCode();

for (const exportNode of exportNodes) {
/** @type {string[]} */
let exportNames = [];
if (exportNode.declaration) {
// @ts-expect-error xxx typedef
if (exportNode.declaration.declarations) {
// @ts-expect-error xxx typedef
for (const declaration of exportNode.declaration.declarations) {
if (declaration.id.type === 'ObjectPattern') {
for (const prop of declaration.id.properties) {
exportNames.push(prop.key.name);
}
} else {
exportNames.push(declaration.id.name);
}
}
} else if (exportNode.declaration.type === 'FunctionDeclaration') {
// @ts-expect-error xxx typedef
exportNames.push(exportNode.declaration.id.name);
}
} else if (exportNode.specifiers) {
for (const spec of exportNode.specifiers) {
exportNames.push(spec.exported.name);
}
}

const missingHardenCalls = [];
for (const exportName of exportNames) {
const hasHardenCall = sourceCode.ast.body.some(statement => {
return (
statement.type === 'ExpressionStatement' &&
statement.expression.type === 'CallExpression' &&
// @ts-expect-error xxx typedef
statement.expression.callee.name === 'harden' &&
statement.expression.arguments.length === 1 &&
// @ts-expect-error xxx typedef
statement.expression.arguments[0].name === exportName
);
});

if (!hasHardenCall) {
missingHardenCalls.push(exportName);
}
}

if (missingHardenCalls.length > 0) {
const noun = missingHardenCalls.length === 1 ? 'export' : 'exports';
context.report({
node: exportNode,
message: `The named ${noun} '${missingHardenCalls.join(', ')}' should be followed by a call to 'harden'.`,
fix: function (fixer) {
const hardenCalls = missingHardenCalls
.map(name => `harden(${name});`)
.join('\n');
return fixer.insertTextAfter(exportNode, `\n${hardenCalls}`);
},
});
}
}
},
};
},
};
6 changes: 4 additions & 2 deletions packages/eslint-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"author": "Endo contributors",
"main": "./lib/index.js",
"scripts": {
"test": "exit 0",
"test": "mocha",
"test:xs": "exit 0",
"build": "exit 0",
"lint-fix": "exit 0",
Expand All @@ -24,7 +24,9 @@
"typescript-eslint": "^7.3.1"
},
"devDependencies": {
"eslint": "^8.57.0"
"@types/mocha": "^10",
"eslint": "^8.57.0",
"mocha": "^10.6.0"
},
"engines": {
"node": ">=0.10.0"
Expand Down
250 changes: 250 additions & 0 deletions packages/eslint-plugin/test/harden-exports.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
const { RuleTester } = require('eslint');
const rule = require('../lib/rules/harden-exports');

const jsValid = [
{
code: `
export const a = 1;
harden(a);
export const b = 2;
harden(b);
`,
},
{
code: `
export const a = 1;
harden(a);
export const b = 2;
harden(b);
`,
},
{
code: `
export function foo() {
console.log("foo");
}
harden(foo);
export const a = 1;
harden(a);
`,
},
{
code: `
export const a = 1;
harden(a);
export function bar() {
console.log("bar");
}
harden(bar);
`,
},
{
code: `
export const a = 1;
harden(a);
export function
multilineFunction() {
console.log("This is a multiline function.");
}
harden(multilineFunction);
`,
},
{
code: `
export const {
getEnvironmentOption,
getEnvironmentOptionsList,
environmentOptionsListHas,
} = makeEnvironmentCaptor();
harden(getEnvironmentOption);
harden(getEnvironmentOptionsList);
harden(environmentOptionsListHas);
`,
},
];

const invalid = [
{
code: `
export const a = 'alreadyHardened';
export const b = 'toHarden';
harden(a);
`,
errors: [
{
message:
"The named export 'b' should be followed by a call to 'harden'.",
},
],
output: `
export const a = 'alreadyHardened';
export const b = 'toHarden';
harden(b);
harden(a);
`,
},
{
code: `
export const a = 1;
`,
errors: [
{
message:
"The named export 'a' should be followed by a call to 'harden'.",
},
],
output: `
export const a = 1;
harden(a);
`,
},
{
code: `
export function foo() {
console.log("foo");
}
`,
errors: [
{
message:
"The named export 'foo' should be followed by a call to 'harden'.",
},
],
output: `
export function foo() {
console.log("foo");
}
harden(foo);
`,
},
{
code: `
export function
multilineFunction() {
console.log("This is a multiline function.");
}
`,
errors: [
{
message:
"The named export 'multilineFunction' should be followed by a call to 'harden'.",
},
],
output: `
export function
multilineFunction() {
console.log("This is a multiline function.");
}
harden(multilineFunction);
`,
},
{
code: `
export const a = 1;
export const b = 2;
export const alreadyHardened = 3;
harden(alreadyHardened);
export function foo() {
console.log("foo");
}
export function
multilineFunction() {
console.log("This is a multiline function.");
}
`,
errors: [
{
message:
"The named export 'a' should be followed by a call to 'harden'.",
},
{
message:
"The named export 'b' should be followed by a call to 'harden'.",
},
{
message:
"The named export 'foo' should be followed by a call to 'harden'.",
},
{
message:
"The named export 'multilineFunction' should be followed by a call to 'harden'.",
},
],
output: `
export const a = 1;
harden(a);
export const b = 2;
harden(b);
export const alreadyHardened = 3;
harden(alreadyHardened);
export function foo() {
console.log("foo");
}
harden(foo);
export function
multilineFunction() {
console.log("This is a multiline function.");
}
harden(multilineFunction);
`,
},
{
code: `
export const {
getEnvironmentOption,
getEnvironmentOptionsList,
environmentOptionsListHas,
} = makeEnvironmentCaptor();
`,
errors: [
{
message:
"The named exports 'getEnvironmentOption, getEnvironmentOptionsList, environmentOptionsListHas' should be followed by a call to 'harden'.",
},
],
output: `
export const {
getEnvironmentOption,
getEnvironmentOptionsList,
environmentOptionsListHas,
} = makeEnvironmentCaptor();
harden(getEnvironmentOption);
harden(getEnvironmentOptionsList);
harden(environmentOptionsListHas);
`,
},
];

const jsTester = new RuleTester({
parserOptions: { ecmaVersion: 2015, sourceType: 'module' },
});
jsTester.run('harden JS exports', rule, {
valid: jsValid,
invalid,
});

const tsTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: { ecmaVersion: 2015, sourceType: 'module' },
});
tsTester.run('harden TS exports', rule, {
valid: [
...jsValid,
{
// harden() on only value exports
code: `
export type Foo = string;
export interface Bar {
baz: number;
}
`,
},
],
invalid,
});
Loading

0 comments on commit d6a1b13

Please sign in to comment.