如何使用 Slang 编写自己的 Solidity linter

Slang 旨在提升下一代 Solidity 代码分析和开发工具, 将展示如何使用 Slang 在仅 25 行代码中编写一个简单的 Solidity linter

Slang 是 Nomic Foundation 的一套模块化编译器 API,旨在提升下一代 Solidity 代码分析和开发工具。它是用 Rust 编写的,并以多种语言分发。目前处于 alpha 阶段并在积极开发中,但已经对许多事情有用!查看 初始 alpha 版本发布公告 了解更多信息。

在本指南中,我们将展示如何使用 Slang 在仅 25 行代码中编写一个简单的 Solidity linter。为了选择一个简单但真实的例子,我们将编写我们自己的 solhint [avoid-tx-origin](https://solhint-community.github.io/solhint-community/docs/rules/security/avoid-tx-origin.html) 规则版本,该规则在代码中使用 tx.origin 时发出警告。

让我们开始吧!

官方 Solidity 文档包含一个示例,说明了为什么使用 tx.origin 进行授权是个坏主意:

// SPDX-License-Identifier: GPL-3.0  
pragma solidity >=0.7.0 <0.9.0;  
// THIS CONTRACT CONTAINS A BUG - DO NOT USE  
contract TxUserWallet {  
    address owner;constructor() {  
        owner = msg.sender;  
    }

function transferTo(address payable dest, uint amount) public {  
        // THE BUG IS RIGHT HERE, you must use msg.sender instead of tx.origin  
        require(tx.origin == owner);  
        dest.transfer(amount);  
    }  
}

我们希望我们的 linter 能够简要报告有问题的代码位置:

example.sol:13:17: warning: avoid using `tx.origin`

与大多数 linter 手动遍历语法树以查找源代码模式不同,我们可以使用 Slang 的树查询语言。它允许我们简洁地指定我们正在寻找的模式,查询引擎将为我们完成所有繁重的工作!

为了让你了解我们的目标,以下是一个预览:

// Query that we’ll use to find the `tx.origin` expression  
const query = Query.parse(  
  `@txorigin [MemberAccessExpression  
     ... [Expression  ... ["tx"] ...]  
     ... [MemberAccess ... ["origin"] ...]  
     ...  
   ]`,  
);// Parse the source code:  
const language = new Language("0.8.22");  
const output = language.parse(NonterminalKind.SourceUnit, contents);

// Query the parsed code...  
const cursor = output.createTreeCursor();  
const matches = cursor.query([query]);

// ...and print the results:  
let match = null;  
while ((match = matches.next())) {  
    const txorigin = match.captures.txorigin[0];  
    const { line, column } = txorigin.textOffset;  
    console.warn(`${filePath}:${line + 1}:${column + 1}: warning: avoid using \\`tx.origin\\`\`);  
}

这正是我们想要的输出!让我们深入了解如何逐步实现这一目标。

安装

首先,我们需要安装 Slang。编译器是用 Rust 编写的,并作为 Rust 包和带有 TypeScript 定义的 NPM 包分发。在本指南中,我们将使用后者。

让我们打开终端并创建一个新项目:

mkdir my-awesome-linter/  
cd my-awesome-linter  
npm init  
npm install @nomicfoundation/slang

设置 TypeScript

我们将使用 TypeScript 编写我们的 linter。让我们安装它并创建一个 tsconfig.json 文件:

npm install --save-dev typescript @types/node  
npx tsc --init

解析 Solidity 代码

为了分析代码,我们需要将其解析为具体语法树(CST)。CST 可以表示不完整或无效的代码,是编写 linter 的良好起点。

让我们从编写一个简单的 index.ts 开始,该文件读取作为第一个命令行参数指定的文件内容:

// index.ts  
import fs from 'node:fs';  
const filePath = process.argv[2];  
const contents = fs.readFileSync(filePath, 'utf8');

支持多个版本的 Solidity

Solidity 语言随着时间的推移发生了很大变化,然而 Slang 能够解析今天使用的所有 Solidity 版本,我们认为这些版本是 0.4.11 及更高版本。

假设我们希望与预期在 Solidity 0.8.22 上运行的代码兼容。首先,我们构造一个 Language 类的实例,这是解析 Solidity 代码的主要入口点:

import { Language } from "@nomicfoundation/slang/language";  
const language = new Language("0.8.22");

解析不同的语言结构

要使用 Slang 解析文件,我们将使用 language.parse() 方法,该方法将 NonterminalKind 作为第一个参数,允许我们指定要解析的语言结构。由于我们要解析整个文件,因此我们将使用 NonterminalKind.SourceUnit

import { NonterminalKind } from "@nomicfoundation/slang/kinds";  
const output = language.parse(NonterminalKind.SourceUnit, contents);

检查解析输出

parse 函数返回一个 ParseOutput 对象,其中包含 CST 的根节点 (tree()) 和解析错误列表 (errors()),如果有的话。

为了检查 CST,我们可以将其打印到控制台以查看其外观:

const tree = results.tree();  
console.log(tree.toJSON());  
// Should print something like:  
// {"kind":"SourceUnit","text_len":{...},"children":[...]}

匹配特定的代码模式

我们刚刚将 Solidity 代码解析为一个结构化表示,现在我们可以对其进行分析。

为了分析 CST,我们将使用 Slang 的树查询语言,它专为像我们这样的任务而设计,是手动分析树的一个很好的替代方案,因为它简洁且具有声明性。

树查询是 Query 类的实例,通过解析查询字符串创建,这些字符串匹配特定的 CST 模式,并可选地将变量绑定到它们。语法在 Tree Query Language 参考中进行了描述。

不深入探讨此查询的细节,我们希望匹配 tx.origin 表达式,这是一个 MemberAccessExpression,其左侧是 tx 标识符,右侧是 origin 标识符:

import { Query } from "@nomicfoundation/slang/query";  
let query = Query.parse(  
  `@txorigin [MemberAccessExpression  
          ...        [Expression  ... @start ["tx"] ...]  
          ...        [MemberAccess ... ["origin"] ...]  
          ...  
   ]`,);

这里有很多内容需要解读!让我们分解一下:

  • 树节点用方括号 [] 括起来。
  • 方括号中的第一个名称匹配给定节点的 NonterminalKind
  • 之后是我们期望匹配的子节点列表。
  • ... 是一个通配符,匹配任意数量的子节点。
  • 节点前的 @ 前缀名称是 捕获,用于引用匹配模式的特定节点。

运行查询

查询是使用 Cursor 类执行的,这是遍历语法树的另一种方式,因此我们需要实例化一个从树的根节点开始的游标:

const cursor = results.createTreeCursor();  
// 这是以下代码的简写:  
// results.tree().createCursor({ utf8: 0, utf16: 0, line: 0, column: 0 })

虽然可以使用同一个 cursor 并发运行多个不同的查询,但在我们的例子中,我们只运行一个:

const matches = cursor.query([query]);

要访问匹配的 QueryResult,我们需要重复调用 next() 直到它返回 null

let match = null;  
while (match = matches.next()) {  
    // ... 对匹配的树片段做一些处理  
}

现在,对于每个查询结果,我们可以使用查询中定义的捕获来访问我们感兴趣的节点。

每个 cursor 指向一个节点,但捕获可以根据查询返回多个 cursor。在我们的例子中,@txorigin 将返回一个指向 MemberAccessExpression 节点的 Cursor 数组。

让我们检查由 Cursor 指向的匹配节点的 JSON 表示:

const txorigin = match.captures.txorigin[0];  
console.log(txorigin.node().toJSON());  
// 应该打印出我们匹配的节点:  
// {"kind":"MemberAccessExpression","text_len":{...},"children":[...]}

报告发现

剩下的唯一事情就是向用户报告我们的发现。

因为我们从查询中得到了一个指向有问题节点的 Cursor,我们可以使用它的 .textOffset 属性将其位置映射回源代码。此属性包含 .line.column 属性,正是我们所需要的。值得注意的是,Slang 使用基于 0 的索引,但错误报告/编辑器通常使用更自然的基于 1 的索引,因此我们需要在这些偏移量上加 1 以使用它。

有了这些,我们可以打印出警告信息,通知用户有问题的代码在哪里:

const txorigin = match.captures.txorigin[0];  
const { line, column } = txorigin.textOffset;  
console.warn(`${filePath}:${line + 1}:${column + 1}: warning: avoid using \`tx.origin\``);

要访问节点的完整范围,我们可以使用 cursor 上的 textRange 属性,它返回节点在源代码中的起始和结束偏移量。

我们可以更有创意地将这些信息插入我们选择的自定义格式器中,但目前这就足够了。

整合在一起

以下是我们 linter 的完整代码:

// file: index.ts  
import fs from "node:fs";  
import { Language } from "@nomicfoundation/slang/language";  
import { NonterminalKind } from "@nomicfoundation/slang/kinds";  
import { Query } from "@nomicfoundation/slang/query";const filePath = process.argv[2];  
const contents = fs.readFileSync(filePath, "utf8");

const language = new Language("0.8.22");  
const output = language.parse(NonterminalKind.SourceUnit, contents);  
const query = Query.parse(  
  `@txorigin [MemberAccessExpression  
     ...  
     [Expression  ... ["tx"] ...]  
     ...  
     [MemberAccess ... ["origin"] ...]  
   ]`,  
);

const cursor = output.createTreeCursor();  
const matches = cursor.query([query]);

let match = null;  
while ((match = matches.next())) {  
    const txorigin = match.captures.txorigin[0];  
    const { line, column } = txorigin.textOffset;  
    console.warn(`${filePath}:${line + 1}:${column + 1}: warning: avoid using \`tx.origin\``);  
}

如果我们不计算空行,代码确实只有 25 行! 🎉

结论

在本指南中,我们演示了如何使用 Slang 为 Solidity 创建一个简单的 linter,实现了 solhintavoid-tx-origin 规则的简单版本,仅用了 25 行代码。

我们涵盖了解析 Solidity 代码的基本知识,识别特定代码模式,并以清晰简明的方式向用户报告发现。

我们希望本指南能激发你编写自己的 linter 或任何其他使用 Slang 操作 Solidity 代码的工具!

如果你有任何问题或反馈,请随时在 GitHub 上联系我们,或查看 Slang 的 文档

本文由 AI 翻译,欢迎小伙伴们来校对

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Igor Matuszewski
Igor Matuszewski
江湖只有他的大名,没有他的介绍。