ProGuard 进阶系列(二)配置解析

2023-10-26

书接上文,从开源库中把代码下载到本地后,就可以在 IDE 中进行运行了。从 main 方法入手,可以看到 ProGuard 执行的第一步就是去解析参数。本文的内容主要分析源码中我们配置的规则解析的实现。

在上一篇文章末尾,在 IDE 中,添加了 @/Users/xxx/debug_proguard.pro 作为函数运行的入参,将配置文件的路径传递给 ProGuard 使用。先来看一下 Main 函数中的代码:

13b5f2e1a15b545c00663a5769dd61c2.png
ProGuard 的 Main 函数代码

从这几行代码可以看出,ProGuard 的大体运行逻辑。在代码 518 行中,通过入参 args系统属性配置 创建了一个配置解析器 ConfigurationParser ,随后调用其 parse 方法,解析传入的参数,并将结果放到 configuration 中,以供后续混淆逻辑使用。

try-with-resources 语法

在代码 518 行处,创建 ConfigurationParser 时,使用了 Java 1.7 中提供的 try-with-resources 语法。此语法可以帮助我们关闭流。举个例子,我们现在需要从一个文件中读取第一行内容。在 Java 1.7 之前,代码将会如下:

static String readFirstLineFromFile(String path) throws IOException {
    FileReader fr = new FileReader(path);
    BufferedReader br = new BufferedReader(fr);
    try {
        return br.readLine();
    } finally {
        br.close();
        fr.close();
    }
}

从代码中可以看到,在 finally 代码块中,需要手动对 FileReaderBufferedReader 进行关闭。而使用 try-with-resources 语法后,就无需手动调用 close 方法。示例代码如下:

static String readFirstLineFromFile(String path) throws IOException {
    try (FileReader fr = new FileReader(path); BufferedReader br = new BufferedReader(fr)) {
        return br.readLine();
    }
}

这样,代码能精简很多,close 也不会因为开发者的疏忽而被遗漏。

配置读取

为了更好地理解整个读取与解析的内容,我画了一个简单的流程图。在 ProGuard 中读取配置文件的逻辑中,会按照一个个单词 为单位进行读取,根据代码中的流程,绘制如下流程图,能够更好地理解代码内容。

c78ea029a100fcceedb8ee191fada3c0.png

解析流程图

根据上面的流程图,在来看源码实现。首先是 ConfigurationParser 的构造方法,实现代码如下:

public ConfigurationParser(String[] args, Properties properties) throws IOException {
  this(args, null, properties);
}

public ConfigurationParser(String[] args, File baseDir, Properties properties) throws IOException {
 this(new ArgumentWordReader(args, baseDir), properties);
}

public ConfigurationParser(WordReader reader, Properties properties) throws IOException{
  this.reader     = reader;
  this.properties = properties;
 readNextWord();
}

在构造方法中,使用入参中的 args系统属性配置 创建了一个 ArgumentWordReader。顾名思义,它是用来读取运行代码时传入的程序参数的。

9f55d679b05bf9c83772c1b190508b3d.png

WordReader 类图

WordReader 的设计中,内容读取是按行读取的。在 LineWordReaderFileWordReader 中,直接使用 LineNumberReader 按行读取。而对于 ArgumentWordReader,实现逻辑会更简单一些,直接将前面提到的 args 数组中的每一个 String 作为一行字符串处理。

接下来,在看构造方法的最末尾:调用了 readNextWord() 方法,此为流程中开始读取下一个单词,也是为了获取第一个 「单词」。来看一下代码是如何实现的:

private void readNextWord() throws IOException {
  readNextWord(false, false);
}

private void readNextWord(boolean isFileName, boolean expectSingleFile) throws IOException {
  nextWord = reader.nextWord(isFileName, expectSingleFile);
}

代码的逻辑里最终调用了 reader.nextWord,此处的 reader 就是刚才提到的 ArgumentWordReader。运行时会使用它去读取第一个「单词」。讲到这里,不由得让我想起了大学时编译原理中讲的 词法分析器。有感兴趣的同学可以去巩固一下《编译原理》。因为 ProGuard 定义的规则相对简单,所以此处的逻辑比一门编程语言简单许多。在运行代码时,只传了一个参数:@/Users/xxx/debug_proguard.pro。在解析时,它会作为一行直接进行处理。先来看一下代码:

public String nextWord(boolean isFileName, boolean expectSingleFile) throws IOException {
  currentWord = null;
  // 省略部分代码
  while (currentLine == null || currentIndex == currentLineLength) {
    // 读取有效的参数行
    currentLine = nextLine();
  }

  // Find the word starting at the current index.
  int startIndex = currentIndex;
  int endIndex;

  // 取第一个字符
  char startChar = currentLine.charAt(startIndex);

 // 省略部分代码
  
  else if (isDelimiter(startChar)) {
  // 如果是分格符,如 @, {, }, (, )等符号
    endIndex = ++currentIndex;
  }
  else {
    // 其它情况处理逻辑
  }

  // 截取,此处的 currentWord 就是解析出来的 @ 符号
  currentWord = currentLine.substring(startIndex, endIndex);
 
  return currentWord;
}


// 是否为分隔符,如果是,则返回 true
private boolean isDelimiter(char character) {
  return isStartDelimiter(character) || isNonStartDelimiter(character);
}
private boolean isStartDelimiter(char character) {
  return character == '@';
}
private boolean isNonStartDelimiter(char character) {
  return character == '{' ||
          character == '}' ||
          character == '(' ||
          character == ')' ||
          character == ',' ||
          character == ';' ||
          character == File.pathSeparatorChar;
}

在读取的过程中,首先将整行数据存储在 currentLine 中,当前此处为 @/Users/xxx/debug_proguard.pro,紧接着会从 currentLine 中取 第一个 字符,因为 @  是分隔符,因此会将它作为第一个 「单词」。代码执行到这里,构造方法里面涉及的逻辑也执行结束,ConfigurationParser 创建完成。下一步就是调用 parse 方法,去执行解析操作,代码如下:

public void parse(Configuration configuration) throws ParseException, IOException {
  while (nextWord != null) {
    // 是 @ 或者是 -include 执行
   if (ConfigurationConstants.AT_DIRECTIVE.startsWith(nextWord) || ConfigurationConstants.INCLUDE_DIRECTIVE.startsWith(nextWord))
     configuration.lastModified = parseIncludeArgument(configuration.lastModified);
    
    // 省略其它代码
  }
}

parse 方法中,会循环遍历所有的 「单词」,直到所有单词都处理完毕。现在只需要看 @ 的处理逻辑,在代码中,如果当前 「单词」为 @-include 时,会调用 parseIncludeArgument 去实现解析的逻辑。  @ 符号的定义是 以递归的方式从给定的文件中读取配置选项 , 从它的定义就可以看出来, parseIncludeArgument 会去解析 @ 后指的文件名称,并读取文件内容。

private long parseIncludeArgument(long lastModified) throws ParseException, IOException{
  // 读取 @ 后面跟着的文件名
  readNextWord("configuration file name", true, true, false);
  URL url = null;
  try {
    // Check if the file name is a valid URL.
    url = new URL(nextWord);
  } catch (MalformedURLException ex) {
  }
  if (url != null) {
    // 给当前 reader 设置一个 includeWordReader
    reader.includeWordReader(new FileWordReader(url));
  }
 // 省略部分代码
  readNextWord();
  return lastModified;
}

代码中可以看到,在执行时,首先会调用 readNextWord 去获取文件名。与前面 @ 获取类似,从 currentLine 中读取出剩下的部分,作为文件名称。获取到文件名称后,就会直接使用这个名称去创建一个 FileWordReader,用于读取此文件中的内容。当然,这里创建的 FileWordReader 还需要赋值给 ArgumentWordReader 的成员变量 includeWordReader。调用 ArgumentWordReadernextWord 方法时,会先调用 includeWordReader.nextWord(xx, xx) 方法,以此来实现递归读取配置文件,实现 @ 符号所定义的功能,如前面的流程图所示。调用 includeWordReader 去获取下一个「单词」的逻辑如下:

if (includeWordReader != null) {
  // 读取下一个字符
  currentWord = includeWordReader.nextWord(isFileName, expectSingleFile);
  if (currentWord != null) {
   return currentWord;
  }
  // 读取完成后,将 reader 关掉,并且置空
  includeWordReader.close();
  includeWordReader = null;
}

此处的 FileWordReaderArgumentWordReader 的核心逻辑基本相似。在 FileWordReader 中,nextLine 方法从指定的文件中读取真实的数据行,而文件行读取使用的是 JDK 中的 LineNumberReader。逻辑不复杂,有兴趣的朋友可自行查阅原文。

配置解析

从文件中读取到配置信息后,需要解析当前的「单词」,并按照固定的逻辑进行处理。在前面的内容中,已经涉及到了配置解析。在 ProGuard 中,有许多配置和不同的规则,可以通过查看源代码来了解。在 ProGuard 中,配置规则分为多个类别。下面将从 输入/输出选项-keep 选项 这两个部分进行分析,以点带面,了解 ProGuard 中配置解析的逻辑。

输入/输出选项

先来看 -injars-outjars-libraryjars 的实现,当读取到这几个单词时,解析的执行如下:

2f69b4961f1910ac195bef3e42046dcd.png

jars 相关解析

可以看到,这三个参数的解析,都是调用的 parseClassPathArgument  来实现的,且 -injars-outjars 都是放在 configuration.programJars 中的。 以 Android 的项目为例,编译结束时,会生成 R.jar 文件,以及一个 classes 文件夹,因此 -injars 的配置如下:

-injars /project_dir/build/intermediates/compile_r_class_jar/release/R.jar
-injars /project_dir/build/intermediates/javac/release/classes

因此,在解析方法中,按文件路径读取下一个「单词」,然后添加到对应的 classpath 中即可。在源代码中,还会存在文件分割符等逻辑,直接上代码:

private ClassPath parseClassPathArgument(ClassPath classPath, boolean isOutput, boolean allowFeatureName) {
    // 读取第一个文件路径
    readNextWord("jar or directory name", true, false, false);
    while (true) {
        // 创建一个 ClassPathEntry
        ClassPathEntry entry = new ClassPathEntry(file(nextWord), isOutput, featureName);
        // 读取下一个单词,可能是文件分隔符,在 mac os 中为 :
        readNextWord();

        // …… 省略读取 filter 的代码  ……

        // 将 ClassPathEntry 添加到 classpath 中
        classPath.add(entry);
        // 是否已经读取完成了? 如果只有一个文件名,如示例中的,就直接结束了。
        if (configurationEnd()) {
            return classPath;
        }
        // 如果不为 路径分隔符 ,直接抛异常
        if (!nextWord.equals(System.getProperty("path.separator"))) {
            throw new ParseException("Expecting class path separator '" + ConfigurationConstants.JAR_SEPARATOR_KEYWORD + "' before " + reader.locationDescription());
        }
        // 读取下一个文件路径
        readNextWord("jar or directory name", true, false, false);
    }
}

-keep 选项

在写 ProGuard 规则中, keep 的规则是相对比较复杂的,根据个人的理解,将 keep 的解析规则用 EBNF 进行描述,如下所示,能够更好的理解其逻辑。

ffcafbcc6e7f93fcf004370379c8bbf4.png

ProGuard EBNF 描述

解析思路与 输入/输出选项 类似,先根据当前的单词判断是否为 keep_keywords ,代码如下:

ce502b021b2d361a221d1dc7e1738180.png

keep 解析分支代码

从代码中可以看到,所有 keep_keywords 的解析都调用到了 parseKeepClassSpecificationArguments 中,些方法的解析逻辑,与 EBNF 中描述的基本一致,先看代码执行的流程图:

8f40b4202f221fc1d6091f7aa9d3f2c0.png

解析 Keep 后的描述符

代码中实现逻辑与上述流程图一致, 源码如下:

while (true) {
    // 1. 读取 -keep 后的单词,
    // 例如配置规则为: -keep class com.example.MainClass 
    // 则此时读取的单词为 class
    readNextWord("keyword ...", false, false, true);

    // 2. 判断读了的单词是否为 「,」 号,如果是,后面会跟其它命令,
    // 例如配置规则为:
    // -keep, allowobfuscation class Test
    // 此时 nextWord 的值就为 「,」
    if (!ConfigurationConstants.ARGUMENT_SEPARATOR_KEYWORD.equals(nextWord)) {
       // 如果不为 「,」 则直接退出循环
        break;
    }

    // 3. 读取后面的 allowshrinking / allowoptimization / allowobfuscation 等
    readNextWord("keyword '" + ConfigurationConstants.ALLOW_SHRINKING_SUBOPTION + "'");

    // 4. 标记参数
    if (ConfigurationConstants.ALLOW_SHRINKING_SUBOPTION.startsWith(nextWord)) {
        allowShrinking = true;
    } else if (ConfigurationConstants.ALLOW_OPTIMIZATION_SUBOPTION.startsWith(nextWord)) {
        allowOptimization = true;
    } else if (ConfigurationConstants.ALLOW_OBFUSCATION_SUBOPTION.startsWith(nextWord)) {
        allowObfuscation = true;
    } else {
        throw new ParseException("Expecting keyword ...");
    }
}

// 5. 解析配置规则后的 class_specification
ClassSpecification classSpecification = parseClassSpecificationArguments(false, true, false);

有前面的 EBNF 描述以及流程图,代码逻辑看起来就会非常的简单。紧接着是解析 class_specification ,先来看一下它的 EBNF 描述,如下图所示:

56d4b3747e3aeea56b81e28db4094175.png

class EBNF 描述

PS: 在 ProGuard 的使用文档中,也有描述 class_specification 的信息,但是并非是 EBNF 格式,有兴趣的同学可以看看: https://www.guardsquare.com/manual/configuration/usage#classspecification

根据 EBNF 的描述,就可以按照其描述规则进行解析。但上面的描述中,还有 annotation_nameclass_namemethod_namereturn_typeargument_typefield_type 等标识符的描述并没有写出来。这里,写需要对他们进行简单的梳理。因为这个名称都是用来描述 Java 中相应的 类名方法名变量名 的信息,所以:

  1. 这些名称一定是符合 java 标识符的规则,即它们由数字(0~9)、字母(a~z 和 A~Z)以及 $ _ 组成,且第一个符号只能是字母、 $_ 中的一个。

  2. annotation_nameclass_namefield_type 等实际描述的是 Java 类名时,使用的是全路径信息,其中包含包名路径,因此名称会出现 . 这个符号 , 例如: com.example.Testjava.lang.Object

  3. 在描述方法返回值(retrun_type)、方法参数(argument_type)或变量类型(field_type) 时,可能会有数组存在,所以 [] 也可能会出现,例如: public java.lang.Object[] getList();

  4. 在 ProGuard 规则中,名称还能使用通配符,其中包括 *.<n>%

基于这些规则,先来看一下代码实现:

private void checkJavaIdentifier(String expectedDescription, boolean allowGenerics) throws ParseException {
    if (!isJavaIdentifier(nextWord)) {
        throw new ParseException("Expecting ...");
    }

    if (!allowGenerics && containsGenerics(nextWord)) {
        throw new ParseException("Generics are not allowed (erased) in ..."));
    }
}

public boolean isJavaIdentifier(String word) {
    if (word.length() == 0) {
        return false;
    }
    for (int index = 0; index < word.length(); index++) {
        char c = word.charAt(index);
        if (!(Character.isJavaIdentifierPart(c) || c == '.' || 
        c == '[' || c == ']' || c == '<' || c == '>' || c == '-' || 
        c == '!' || c == '*' || c == '?' || c == '%')) {
            return false;
        }
    }

    return true;
}

看完标识符的匹配规则,在来看完整定义的 annotation_nameclass_name 等名称的读取逻辑,在代码中,都会调用到 parseCommaSeparatedList 里面去,顾名思义,此方法会根据 , 解析一个列表出来,直接看代码:

6ea493c497d7e00ba9cf8856b6ef352c.png

annotation type 读取

代码中仅保留了关键代码,从注释中可以看到,拿到「单词」后,会先检查是否为一个合法标识符,如果符合,就添加到列表中去,并读取下一个「单词」,如果是 , 会继续上述逻辑进行添加,反之返回列表。

在回到上层的解析逻辑中来,根据 EBNF 的描述, 需要先判断是否有 annotation_type  和  access_flag, 这一块的逻辑如下:

09f7b97dea6b90d6e78e6ec20310539c.png

annotation type 解析

其中当解析到当前单词为 @ 时,会去解析 annotation_name 的列表, 并且重新用 , 拼接成一个字符串存储起来。 剩下的 access_flag 就简单多了,直接使用一个 int 型的值,按位将存起来就可以了,当然,我们还需要注意 ! ,因此,在存储的时候,会有两个变量:

if (!negated) {
  requiredSetClassAccessFlags |= accessFlag;
} else {
  requiredUnsetClassAccessFlags |= accessFlag;
}

后面的 class_nameextends  等逻辑读取就比较简单了,如下:

7865e1d657a8d43af2220f31c093ab5c.png

解析 ClassName 以及 extends 父类

当父类相关信息解析完成后,下一步便是解析方法和变量相关的信息,也就是 EBNF 中描述 field_specificationmethod_specification 的内容。在 ProGuard 中,这两部份类容统一放到了 parseMemberSpecificationArguments 中去实现了,先来看一下代码逻辑:

a2ddca6ec563fbe1a7e9ac7ddef863e8.png

类成员解析入口

剩下解析类成员的逻辑与前面类解析的逻辑相似,按照 EBNF 格式进行解析即可, 感兴趣的同学可以自行阅读源码。

结语

ProGuard 配置文件解析是非常重要的一部分内容,在 ProGuard 后续的执行逻辑中,会经常使用到本文中解析出来配置信息,可从官方文档详细了解一下各配置选项的作用级使用方法,以便能更好的理解后面的内容。在解析配置文件中,提到的 EBNF 是描述计算机编程语言的上下文无关文法的符号表示法,在编程语言开发中可能会经常遇到,此语法不复杂,可以去百科上读一读,相信你会有很多的收获。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

ProGuard 进阶系列(二)配置解析 的相关文章

随机推荐

  • AIGC基础:从VAE到DDPM原理、代码详解

    作者 王建周 单位 来也科技AI团队负责人 研究方向 分布式系统 CV NLP 前言 AIGC 目前是一个非常火热的方向 DALLE 2 ImageGen Stable Diffusion 的图像在以假乱真的前提下 又有着脑洞大开的艺术性
  • 我们真的需要复杂的密码吗?

    目录 toc 现状 想写这篇文章很久了 不过作为一个安全行业的从业者 总觉得说出来有些汗颜 我们这个行业的安全人员总是引导甚至强制灌输人们设置复杂密码的做法 让我一直觉得写这篇文章是在对我们的自我否定 自我打脸 所以也就一直没有写 直到我的
  • 元宇宙概念火热,多家企业推出NFT

    摘要 产业动态 Facebook 计划未来五年在欧洲招聘 1 万人建立元宇宙 新加坡新跃社科大学成立元宇宙实验室 淘宝APP上线 天猫双11首届元宇宙艺术展 格拉斯哥大学与VB Hyperledger合作启动Moshan区块链实验室 政策相
  • Robotium学习笔记三

    以下是从网络上抄录的一些Robotium注意事项 1 有些button没有string 没有text 只能通过index来click这样很不直观 而且button的index并不是固定的 有可能随着控件重新加载 顺序也有可能发生变化 无法保
  • 获取jsp各种参数方法总结

    package coreservlets import java io import javax servlet import javax servlet http import java util Creates a table show
  • C++学习(五十四)qt发布mac程序

    当你用Qt开发好程序后 是不是会很期待将你的成果分享给你的小伙伴 可是Qt的库并不是OS X标配的 所以我们要自己去复制库到app包里 才可以让app在其他未安装Qt的电脑上运行 比较幸运的是 Qt为我们提供了macdeployqt工具 借
  • 端到端深度学习与自动驾驶(含参考文献)

    参考文献见最后 1 自动驾驶系统的分类 Rule based system基于规则的系统 也有论文中将这样的方法叫做Mediated percepiton approach Fully end to end 端到端的系统 也有论文中叫做be
  • IT产业的70:20:10规律

    IT产业的发展是迅速而无法抗拒的 一家技术优秀 管理正规的互联网公司从奠基到上市往往只需要很少的时间 例如英特尔和微软从上市起用了十年的时间确立了它们在微机领域的霸主地位 并达到百亿产值 而思科上市后只用了五年左右的时间就主导了网络硬件的市
  • day39 动态规划

    62 不同路径 机器人每次只可以向右 或者 向下 每次向右走 dp i 0 1 dp 0 j 1 dp i j dp i 1 j dp i j 1 i的范围 0 m 1 j的范围 0 n 1 63 不同路径 II 解法同上 需要考虑障碍物
  • MQTT 官方资源地址

    MQTT官方资源地址 http mqtt org MQTT的官方地址 https www eclipse org paho downloads php MQTT源码的下载地址 官网源码 请参见官方资料和源代码 以免少走弯路 陷入大坑
  • 数据挖掘技术-绘制饼图

    绘制饼图 前置步骤 准备数据guomin npz 下载数据guomin npz到Linux本地的 course DataAnalyze data目录 绘制饼图 pyplot中绘制饼图的函数为pie 使用pie函数绘制2017年第一季度各产业
  • oracle表的storage参数说明

    author skatetime 2009 05 12 修改表的存储参数 storage 解釋 pctfree和pctused 用來控制數據塊中的空閑空間的使用 空閑空間用於數據行的插入和更新 initrans和maxtrans 用來控制分
  • Netty和Tomcat的区别

    一 Netty和Tomcat有什么区别 Netty和Tomcat最大的区别就在于通信协议 Tomcat是基于Http协议的 他的实质是一个基于http协议的web容器 但是Netty不一样 他能通过编程自定义各种协议 因为netty能够通过
  • GIT简单介绍及常用命令

    git是什么 git是目前企业使用最多最流行的 分布式版本 控制系统 分布式版本控制系统 没有中央服务器 每个人的电脑就是一个完整的版本库 工作时候不需要联网 因为版本就在自己的电脑上面 如果多人同时编写修改一个文件的时候 只需要将两者之间
  • matlab快捷键自动对齐

    matlab用了一段时间发现移动代码的时候很容易出现对不齐的情况 一行行缩进很让人头疼 后面发现原来可以自动缩进 方法如下 matlab中全选某段代码 Ctrl i 可以代码自动排版
  • esh的snapshot快照备份

    1 Elasticsearch的snapshot快照备份 优点 通过snapshot拍摄快照 然后定义快照备份策略 能够实现快照自动化存储 可以定义各种策略来满足自己不同的备份 缺点 还原不够灵活 拍摄快照进行备份很快 但是还原的时候没办法
  • 乐蜂网服务器信息,乐蜂网目标独立上市 唯品会向其派驻CEO、CFO

    腾讯科技讯 王可心 2月25日消息 在乐蜂网 今日 唯品会副总裁冯佳路 乐蜂网副总裁辛益华接受媒体采访 解答外界疑问 控股乐蜂网后 为何又参股东方风行集团 在过去10天 唯品会与乐蜂网 东方风行集团分别发生交易 2月14日 唯品会宣布战略投
  • 爬虫的工作原理、挑战和应用

    什么是网络爬虫 网络爬虫 Web Crawler 是一种自动化程序 它能够在互联网上浏览网页 收集信息并将其存储在本地或其他地方供进一步处理和分析 爬虫通常用于搜索引擎 数据挖掘 内容聚合 价格比较等应用中 爬虫的工作原理 发送请求 爬虫从
  • Node.js 全网最详细教程(第三章:Node.js 文件系统模块)

    fs 文件系统模块 fs 模块是Node js 官方提供的 用来操作文件的模块 它提供了一系列的方法和属性 用来满足用户对文件的操作需求 一 fs readFile 异步读取文件 源码解析 参数1 path 读取的路径 参数2 option
  • ProGuard 进阶系列(二)配置解析

    书接上文 从开源库中把代码下载到本地后 就可以在 IDE 中进行运行了 从 main 方法入手 可以看到 ProGuard 执行的第一步就是去解析参数 本文的内容主要分析源码中我们配置的规则解析的实现 在上一篇文章末尾 在 IDE 中 添加