前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >再次揭秘Copilot:sourcemap逆向分析

再次揭秘Copilot:sourcemap逆向分析

作者头像
孟健
发布2023-11-11 20:05:39
1610
发布2023-11-11 20:05:39
举报
文章被收录于专栏:前端工程前端工程

背景

今年五月的时候我写了一篇文章《**花了大半个月,我终于逆向分析了Github Copilot「》,最近发现copilot.map文件也提交了上来,」sourcemap**中包含了整体源码的结构信息和部分变量信息,这无疑为分析copilot源码带来了极大的便利,因此再次分析一波。

image

sourcemap是什么

Sourcemap 是一种用于将编译、打包后的代码映射回原始源代码的技术。它主要用于 JavaScript 的源代码映射(source map),但也可以用于其他编程语言。

在 JavaScript 中,源代码映射(source map)是一种文件,它允许浏览器将压缩、混淆或转译后的代码映射回原始源代码。这对于调试非常有用,因为它允许开发者查看和调试原始源代码,而不是被压缩或混淆的代码。

Sourcemap 文件通常以 .map 扩展名结尾,并且可以通过浏览器的开发者工具查看和使用。

sourcemap的结构

Sourcemap 文件的结构主要包括以下几个部分:

  1. 「Version」: 这是源映射文件的版本号。目前,Sourcemap 的版本号为 3。
  2. 「File」: 这是源映射文件所对应的原始源文件的名称。
  3. 「SourceRoot」: 这是一个可选的字段,它指定了源文件的根路径。
  4. 「Sources」: 这是一个包含所有原始源文件名的数组。
  5. 「Names」: 这是一个包含所有原始源文件中使用的变量、函数和类的名称的数组。
  6. 「Mappings」: 这是一个字符串,它描述了源文件和生成文件之间的映射关系。

下面是一个简单的 Sourcemap 文件的示例:

代码语言:javascript
复制
{
  "version": 3,
  "file": "out.js",
  "sourceRoot": "",
  "sources": ["foo.js", "bar.js"],
  "names": ["src", "maps", "are", "fun"],
  "mappings": "AAAA,SAASA,EAAM,CAAE,GAAG,EAAE,CACC,CAAC"
}

在这个示例中,mappings 字段描述了源文件和生成文件之间的映射关系。这个字符串被分成多个部分,每个部分对应源文件的一行。每个部分由一系列的映射组成,每个映射描述了源文件中的一个字符在生成文件中的位置。

mappings的含义

Sourcemapmappings 字段是一个字符串,它描述了源文件和生成文件之间的映射关系。这个字符串被分成多个部分,每个部分对应源文件的一行。每个部分由一系列的映射组成,每个映射描述了源文件中的一个字符在生成文件中的位置。

每个映射由五个部分组成:

  1. 生成文件中的列号。
  2. 源文件中的行号。
  3. 源文件中的列号。
  4. 源文件中的名称索引。
  5. 源文件中的名称。

每个部分都使用 VLQ(Variable-length quantity)编码,这是一种压缩数字的方法。VLQ 编码使用一个或多个字节来表示一个数字,每个字节的最高位用于指示是否还有更多的字节需要读取。

下面是一个 mappings 字段的示例:

代码语言:javascript
复制
AAAA,SAASA,EAAM,CAAE,GAAG,EAAE,CACC,CAAC

在这个示例中,AAAA 表示生成文件中的第一列对应源文件的第一行第一列,SAASA 表示生成文件中的第二列对应源文件的第二行第二列,以此类推。

source-map库获取源文件信息

Node.js的source-map库可以做map文件的解析:

代码语言:javascript
复制
const sourcemap = require("source-map");

const mapFile = fs.readFileSync("./extension.js.map");
const rawSourceMap = JSON.parse(mapFile.toString());

const nameMap = new Map();
const fileMap = new Map();

await sourcemap.SourceMapConsumer.with(rawSourceMap, null, (consumer) => {
  consumer.eachMapping(function (m) {
    if (m.name) {
      nameMap.set(`${m.generatedLine}:${m.generatedColumn}`, m);
    }

    if (m.source) {
      if (!fileMap.has(m.source)) {
        fileMap.set(m.source, {
          start: m,
        });
      } else {
        fileMap.set(m.source, {
          ...fileMap.get(m.source),
          end: m,
        });
      }
    }
  });
});

上面我们使用source-map工具解析了变量名的映射关系以及对应的源文件路径信息,将解析的结果存在了两个map当中,便于我们后面进行读取。

AST处理节点命名

代码语言:javascript
复制
function updateNodeName(node, name) {
  if (node.type === "VariableDeclarator") {
    node.id.name = name;
  } else if (node.type === "Identifier") {
    node.name = name;
  } else if (node.type === "CallExpression") {
    updateNodeName(node.callee, name);
  } else if (node.type === "ArrowFunctionExpression") {
    node.params[0].name = name;
  } else if (node.type === "ExpressionStatement") {
    updateNodeName(node.expression, name);
  } else if (node.type === "AssignmentExpression") {
    updateNodeName(node.left, name);
  } else if (node.type === "MemberExpression") {
    updateNodeName(node.object, name);
  } else if (node.type === "BinaryExpression") {
    updateNodeName(node.left, name);
  } else if (node.type === "ConditionalExpression") {
    updateNodeName(node.test, name);
  } else if (node.type === "LogicalExpression") {
    updateNodeName(node.left, name);
  } else if (node.type === "SequenceExpression") {
    updateNodeName(node.expressions[0], name);
  } else if (node.type === "UpdateExpression") {
    updateNodeName(node.argument, name);
  } else if (node.type === "StringLiteral") {
    node.value = name;
  } else if (node.type === "AssignmentPattern") {
    updateNodeName(node.left, name);
  } else if (node.type === "ReturnStatement") {
    updateNodeName(node.argument, name);
  } else if (node.type === "BlockStatement") {
    updateNodeName(node.body[0], name);
  } else if (node.type === "UnaryExpression") {
    updateNodeName(node.argument, name);
  } else if (node.type === "ObjectProperty") {
    updateNodeName(node.key, name);
  } else if (node.type === "EmptyStatement") {
    // 好像是start和end标记
    // console.log(name);
  } else if (node.type === "NumericLiteral") {
    node.value = name;
  } else if (node.type === "ThisExpression") {
    // console.log(name);
  } else if (node.type === "ForStatement") {
    updateNodeName(node.init, name);
  } else if (node.type === "OptionalMemberExpression") {
    updateNodeName(node.object, name);
  } else if (node.type === "OptionalCallExpression") {
    updateNodeName(node.callee, name);
  } else if (node.type === "LabeledStatement") {
    updateNodeName(node.label, name);
  } else if (node.type === "ClassPrivateProperty") {
    updateNodeName(node.key, name);
  } else if (node.type === "PrivateName") {
    updateNodeName(node.id, name);
  } else if (node.type === "ClassPrivateMethod") {
    updateNodeName(node.key, name);
  } else if (node.type === "VariableDeclaration") {
    updateNodeName(node.declarations[0], name);
  } else if (node.type === "IfStatement") {
    updateNodeName(node.test, name);
  } else if (node.type === "TryStatement") {
    updateNodeName(node.block, name);
  } else if (node.type === "SwitchStatement") {
    updateNodeName(node.discriminant, name);
  } else {
    // console.log(node);
  }
}

我们封装了一个updateNodeName的方法,用于递归处理AST节点将变量命名给替换(这着实是一个体力活。。。),判断了各类表达式、语句、字面量等等场景。

AST映射源文件路径

对于文件路径我们怎样映射过去呢?目前已知的信息主要有:

  • 对应源代码的location
  • 对应源代码的path
  • 所有具有映射关系的map

一个比较自然的想法是基于代码的位置做字符切割。但是我们上面变量命名替换后,生成的新的代码文件行号和列号都已经发生了变化,无法映射到原来的行列,这条路很难行的通。

所以还是在AST遍历里面处理完比较好。

我们使用两个变量分别记录上一次遍历的文件路径和astnodes

代码语言:javascript
复制
let lastFile;
let lastNodes = [];

然后对AST进行遍历,先处理变量命名:

代码语言:javascript
复制
traverse(ast, {
    enter(p) {
      const node = p.node;
      const { line, column } = node.loc.start;
      
      // 处理变量命名
      if (nameMap.has(`${line}:${column}`)) {
        const sourceObj = nameMap.get(`${line}:${column}`);
        const name = sourceObj.name;
        updateNodeName(node, name);
      }
    },
  });

再处理路径:

代码语言:javascript
复制
traverse(ast, {
    enter(p) {
      const node = p.node;
      const { line, column } = node.loc.start;

      // 处理路径
      for (const [file, m] of fileMap.entries()) {
        if (
          m.start.generatedLine === line &&
          m.start.generatedColumn === column
        ) {
          if (lastFile && lastNodes.length) {
            const pp = path.resolve(__dirname, "./prettier/empty", lastFile);
            fs.ensureFileSync(pp);
            fs.writeFileSync(pp, lastNodes.map(n => generate(n).code).join());
            lastNodes = [];
          }
          lastFile = file;
          break;
        }
      }
      if (lastFile) {
        lastNodes.push(node);
        p.skip();
      }
    },
  });

注意这里的思路是每当start起始对应的文件路径发生变化的时候,生成上一个文件的源码,然后写入到对应的目录文件内。

另外ast是进行数组拼接的,我们需要通过skip方法防止children元素再次被递归到造成代码重复。

copilot的源码结构

最终得到的还原代码如下:

代码语言:javascript
复制
├── extension
│   └── src
│       ├── auth.ts
│       ├── codeReferencing
│       │   ├── codeReferenceEngagementTracker.ts
│       │   ├── compute.ts
│       │   ├── connectionState.ts
│       │   ├── constants.ts
│       │   ├── handleCopliotToken.ts
│       │   ├── handlePostInsertion.ts
│       │   ├── headerContributor.ts
│       │   ├── index.ts
│       │   ├── logger.ts
│       │   ├── matchNotifier.ts
│       │   ├── outputChannel.ts
│       │   ├── snippy
│       │   │   ├── errorCreator.ts
│       │   │   ├── index.ts
│       │   │   ├── network.ts
│       │   │   └── snippy.proto.ts
│       │   └── telemetry
│       │       └── handlers.ts
│       ├── config.ts
│       ├── constants.ts
│       ├── copilotPanel
│       │   ├── common.ts
│       │   ├── copilotListDocument.ts
│       │   └── panel.ts
│       ├── diagnostics.ts
│       ├── experiments
│       │   └── expFilters.ts
│       ├── extension.ts
│       ├── extensionStatus.ts
│       ├── extensionTestApi.ts
│       ├── fileSystem.ts
│       ├── ghostText
│       │   └── ghostText.ts
│       ├── git.ts
│       ├── install
│       │   └── installationManager.ts
│       ├── networkConfiguration.ts
│       ├── proxy.ts
│       ├── session.ts
│       ├── statusBar.ts
│       ├── statusBarPicker.ts
│       ├── suggestions.ts
│       ├── symbolDefinitionProvider.ts
│       ├── telemetry.ts
│       ├── telemetryDelegation.ts
│       ├── textDocument.ts
│       ├── textDocumentManager.ts
│       └── vscodeCommitFileResolver.ts
├── lib
│   └── src
│       ├── auth
│       │   ├── copilotToken.ts
│       │   ├── copilotTokenManager.ts
│       │   ├── copilotTokenNotifier.ts
│       │   └── error.ts
│       ├── changeTracker.ts
│       ├── clock.ts
│       ├── commitFileResolver.ts
│       ├── common
│       │   ├── cache.ts
│       │   ├── debounce.ts
│       │   ├── iterableHelpers.ts
│       │   └── productContext.ts
│       ├── config.ts
│       ├── constants.ts
│       ├── context.ts
│       ├── copilotPanel
│       │   ├── common.ts
│       │   └── panel.ts
│       ├── cursorHistoryManager.ts
│       ├── defaultHandlers.ts
│       ├── diagnostics.ts
│       ├── documentTracker.ts
│       ├── error
│       │   └── userErrorNotifier.ts
│       ├── experiments
│       │   ├── defaultExpFilters.ts
│       │   ├── expConfig.ts
│       │   ├── features.ts
│       │   ├── fetchExperiments.ts
│       │   ├── filters.ts
│       │   ├── granularityDirectory.ts
│       │   └── granularityImplementation.ts
│       ├── ghostText
│       │   ├── completionsCache.ts
│       │   ├── contextualFilter.ts
│       │   ├── contextualFilterConstants.ts
│       │   ├── contextualFilterTree.ts
│       │   ├── copilotCompletion.ts
│       │   ├── debounce.ts
│       │   ├── ghostText.ts
│       │   ├── multilineModel.ts
│       │   ├── multilineModelWeights.ts
│       │   ├── normalizeIndent.ts
│       │   └── telemetry.ts
│       ├── headerContributors.ts
│       ├── installationManager.ts
│       ├── language
│       │   ├── generatedLanguages.ts
│       │   ├── languageDetection.ts
│       │   └── languages.ts
│       ├── logger.ts
│       ├── network
│       │   ├── certificateReaderCache.ts
│       │   ├── certificateReaders.ts
│       │   ├── certificates.ts
│       │   ├── helix.ts
│       │   ├── proxy.ts
│       │   └── proxySockets.ts
│       ├── networkConfiguration.ts
│       ├── networking.ts
│       ├── notificationSender.ts
│       ├── openai
│       │   ├── config.ts
│       │   ├── fetch.fake.ts
│       │   ├── fetch.ts
│       │   ├── openai.ts
│       │   └── stream.ts
│       ├── postInsertion.ts
│       ├── postInsertionNotifier.ts
│       ├── progress.ts
│       ├── prompt
│       │   ├── neighborFiles
│       │   │   ├── cocommittedFiles.ts
│       │   │   ├── cursorHistoryFiles.ts
│       │   │   ├── neighborFiles.ts
│       │   │   ├── openTabFiles.ts
│       │   │   └── workspaceFiles.ts
│       │   ├── parseBlock.ts
│       │   ├── prompt.ts
│       │   ├── promptLibProxy.ts
│       │   ├── repository.ts
│       │   ├── retrieval.ts
│       │   └── symbolDefinition.ts
│       ├── repositoryControl
│       │   ├── constants.ts
│       │   ├── contentRestrictions.ts
│       │   ├── policyEvaluator.ts
│       │   ├── repositoryControl.ts
│       │   └── repositoryControlManager.ts
│       ├── suggestions
│       │   ├── anomalyDetection.ts
│       │   ├── editDistance.ts
│       │   ├── mlConstants.ts
│       │   ├── restraint.ts
│       │   └── suggestions.ts
│       ├── telemetry
│       │   ├── auth.ts
│       │   ├── azureInsights.ts
│       │   ├── azureInsightsReporter.ts
│       │   ├── failbot.ts
│       │   └── userConfig.ts
│       ├── telemetry.ts
│       ├── testing
│       │   ├── config.ts
│       │   ├── copilotToken.ts
│       │   ├── packageRoot.ts
│       │   ├── runtimeMode.ts
│       │   ├── telemetry.ts
│       │   ├── telemetryFake.ts
│       │   ├── testHelpers.ts
│       │   └── tokenManager.ts
│       ├── textDocument.ts
│       ├── textDocumentManager.ts
│       ├── util
│       │   ├── documentEvaluation.ts
│       │   ├── nodeVersion.ts
│       │   ├── opener.ts
│       │   ├── redaction.ts
│       │   ├── shortCircuit.ts
│       │   └── typebox.ts
│       └── workspaceFileSystem.ts
├── package.json
└── prompt
    └── src
        ├── elidableText
        │   └── index.ts
        └── tokenization
            └── index.ts

可以看到,copilot源码大体结构上就是extensionlib两层,extension是整个插件的入口,lib是底层封装的基础能力,在这个清晰的源码结构上,有助于我们进一步分析理解其中的逻辑。

上述代码已经提交在Github上,有需要的小伙伴可自取:

https://github.com/mengjian-github/copilot-analysis-new

小结一下

其实目前的copilot版本webpack混淆压缩要比之前更加难以分析,整个模块信息基本上无法拆离出来了,甚至是他们之间的依赖关系也变得更加模糊。不过copilot团队暴露了sourcemap文件,又可以进一步探求源码的结构和替换一些关键变量,为分析带来了一定的便利。

本文没有像之前那样将主流程再分析一遍,实际上,虽然代码组织结构清晰了,但是源码依旧无法完美还原,sourcemap其实帮助也有限,很多映射还需要不断对比生成代码进行推敲分析,基于这个版本的基础,要想深入了解,还是需要大量的时间和精力推导内在的逻辑实现。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-11-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 孟健的前端认知 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 背景
  • sourcemap是什么
  • sourcemap的结构
  • mappings的含义
  • source-map库获取源文件信息
  • AST处理节点命名
  • AST映射源文件路径
  • copilot的源码结构
  • 小结一下
相关产品与服务
云开发 CLI 工具
云开发 CLI 工具(Cloudbase CLI Devtools,CCLID)是云开发官方指定的 CLI 工具,可以帮助开发者快速构建 Serverless 应用。CLI 工具提供能力包括文件储存的管理、云函数的部署、模板项目的创建、HTTP Service、静态网站托管等,您可以专注于编码,无需在平台中切换各类配置。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档