概述
目标和适用范围
本规范参考业界标准及实践,华为编程实践总结,为提高代码的可读性,可维护性和安全性,提供编程指南,力争系统化、易使用、易检查。
本规范适用于公司内使用仓颉编程语言编写的代码。
本规范不是仓颉语言教程,在参考本规范之前,希望您具有相应的仓颉语言基础知识。
总体原则
仓颉编程遵循通用原则:
- 清晰第一:清晰性是易于维护、易于重构的程序必需具备的特征。
- 简洁为美:简洁就是易于理解并且易于实现。
- 风格一致:相比个人习惯,所有人共享同一种风格带来的好处,远远超出为统一而付出的代价。
安全编码基本思想:
编程过程中应该时刻保持以下的假设:
- 程序所处理的所有外部数据都是不可信的攻击数据
- 攻击者时刻试图监听、篡改、破坏程序运行环境、外部数据
基于以上的假设,得出安全编码基本思想:
-
程序在处理外部数据时必须经过严格的合法性校验 编程人员在处理外部数据过程中必须时刻保持这种思维意识,不能做出任何外部数据符合预期的假设,外部数据必须经过严格判断后才能使用。编码人员必须在这种严酷的攻击环境下通过遵守这一原则保证程序的执行过程符合预期结果。
-
尽量减少代码的攻击面。代码的实现应该尽量简单,避免与外部环境做多余的数据交互,过多的攻击面增加了被攻击的概率,尽量避免将程序内部的数据处理过程暴露到外部环境。
-
通过防御性的编码策略来弥补潜在的编码人员的疏忽 粗心是人类的天性。由于外部环境的不确定性,以及编码人员的经验、习惯的差异,代码的执行过程很难达到完全符合预期设想的情况。因此在编码过程中必须采取防御性的策略,尽量缓解由于编码人员疏忽导致的缺陷。
条款组织方式
每个条款一般包含标题、级别、描述等组成部分。条款内容中的 “正例”表示符合该条款要求的代码片段,“反例”表示不符合该条款要求的代码片段,但不一定造成程序错误的结果。
标题
描述本条款的内容。
规范条款分为原则和规则两个类别,原则可以评价规则内容制定的好坏并引导规则进行相应的调整;规则是需要遵从或参考的实践。通过标题前的编号标识出条款的类别为原则或规则。
标题前的编号规则为:'P' 为单词 Principle 首字母,'G' 为单词 Guideline 的首字母。原则条款的编号规则为 P.Number。规则的编号方式为 G.Element.Number,其中 Element 为领域知识中关键元素(本规范中对应的二级目录)的 3 位英文字母缩略语。Number 是从 1 开始递增的两位阿拉伯数字,不足两位时高位补 0。
级别
规则类条款分为两个级别:要求、建议。
- 要求:表示产品原则上应该遵从,但可以按照具体的产品版本计划和节奏分期实现。
- 建议:表示该条款属于最佳实践,有助于进一步消解风险,产品可结合业务情况考虑是否纳入,但要保证实施一致的代码风格。
描述
对条款的进一步描述,描述条款的原理,配合正确和错误的代码例子作为示范。有的条款还包含一些规则不适用的例外场景。
仓颉语言的安全机制
仓颉提供了很多安全机制来减少潜在的安全风险:
- 类型安全:仓颉语言是静态强类型语言,通过编译时检查尽早发现程序错误,排除运行时类型错误,能够减少整数截断、溢出、回绕的问题,同时仅非常有限地支持隐式转换。
- 自动内存管理:仓颉语言采用垃圾收集机制,支持自动内存管理,杜绝内存泄漏、多次释放等问题。
- 内存安全:仓颉语言在运行时进行数组下标越界检查、溢出检查等,确保程序内存安全。
- 无指针:仓颉语言支持枚举类型,使用由枚举类型定义的 Option 类型解决了空指针问题。同时不能对对象取地址,引用类型不能使用指针的算术运算,所以无法创造出野指针。
- 变量初始化策略:仓颉语言要求所有变量都必须初始化,并且在编译时进行检查,减少忘记赋值导致的安全风险。
仓颉在实现了上述强大的安全机制的同时,也实现了强大的兼容性:仓颉语言通过在 IR 层级上实现多语言的互通,可以高效调用其他主流编程语言,进而实现对其他语言库的复用和生态兼容。但由于仓颉的提供的安全机制仅适用于仓颉语言本身,并不适用于与其他语言交互操作的场景,因此与其他语言交互操作的安全规范请参考语言互操作章节。
代码风格
代码风格一般包含标识符的命名风格、注释风格及排版风格。一致的编码习惯与风格,会使代码更容易阅读、理解,更容易维护。
命名
有意义地、恰当地命名在编程中是一个较难的事。好的命名特征有:能清晰地表达意图,避免造成误导。 少用缩写,但常见词以及业务线的领域词汇都是允许的,比如 response:resp,request:req,message:msg。 使用仓颉语言编程建议各种形式的名字使用统一的命名风格且仅使用纯 ASCII 字符(除 DSL、或从外部库导入等情况),具体如下:
类别 | 命名风格 | 形式 |
---|---|---|
包名和文件名 | unix_like:单词全小写,用下划线分割 | aaa_bbb |
接口,类,结构体,枚举和类型别名 | 大驼峰:首字母大写,单词连在一起,不同单词间通过单词首字母大写分开,可包含数字 | AaaBbb |
变量,函数,函数参数 | 小驼峰:首字母小写,单词连在一起,不同单词间通过单词首字母大写分开。例外:测试函数可有下划线 _ ,循环变量,try-catch 中的异常变量,允许单个小写字母 | aaaBbb |
let 全局变量, static let 成员变量 | 建议全大写,下划线分割 | AAA_BBB |
泛型类型变量 | 单个大写字母,或单个大写字母加数字,或单个大写字母接下划线、大写字母和数字的组合,例如 E, T, T2, E_IN, E_OUT, T_CONS | A |
下表是一些易混淆的单个字符,当作为标识符时,需留意:
易混淆的字符 | 易误导的字符 |
---|---|
O (大写的 o), D (大写的 d) | 0 (zero) |
I (大写的 i), l (小写 L) | 1 (one) |
Z (大写的 z) | 2 (two) |
S (大写的 s) | 5 (five) |
b (小写的 B) | 6 (six) |
B (大写的 b) | 8 (eight) |
q(小写的 Q) | 9 (nine) |
h (小写的 H) | n (小写的 N) |
m (小写的 M) | rn (小写的 RN) |
_ (下划线) | 连续多个时很难分辨究竟有几个 |
另外,在使用大、小驼峰命名风格时若遇到 JSON,HTTP 等首字母缩略词,
应将整个缩略词看做普通单词处理,服从命名风格的大小写规定,而不要维持全大写的写法。
如大驼峰风格中:XmlHttpRequest
,小驼峰风格中:jsonObject
。
包名和文件名
G.NAM.01 包名采用全小写单词,允许包含数字和下划线
【级别】建议
【描述】
- 包名字母全小写,如果有多个单词使用下划线分隔;
- 包名允许有数字,例如 org.apache.commons.lang3;
- 带限定前缀的包名必须和当前包与源代码根目录的相对路径对应,建议以 Internet 域名反转的规则开头,再加上产品名称和模块名称。
【正例】
域名 | 包名 |
---|---|
my_product.example.com | com.example.my_product |
my_product.example.org | org.example.my_product |
G.NAM.02 源文件名采用全小写加下划线风格
【级别】建议
【描述】
- 文件名不采用驼峰的原因是:不同系统对文件名大小写处理不同(如 Windows 系统不区分大小写,但是 Unix/Linux, Mac 系统则默认区分)。
- 如果文件只包含一个包外部可见的顶层元素,那么选择该顶层元素的名称,以此命名。否则,选择能代表主要内容的元素名称作为文件名。源文件名称使用全小写加下划线风格。
【正例】
// my_class.cj
public class MyClass {
// CODE
}
【反例】
// MyClass.cj 文件名不符合:使用了驼峰命名
public class MyClass {
// CODE
}
接口、类、struct、enum 和类型别名
G.NAM.03 接口、类、struct、enum 类型和 enum 构造器、类型别名、采用大驼峰命名
【级别】建议
【描述】
- 类型定义通常是名词或名词短语,其中接口名还可以是形容词或形容词短语,都应采用大驼峰命名。
- enum 构造器采用大驼峰命名风格。
- 测试类命名时推荐以被测试类名开头,并以 Test 结尾。例如,HashTest 或 HashIntegrationTest。
- 建议异常类加
Exception
/Error
后缀。
例外:
在 UI 场景下,一些配置需要用 enum 的成员构造来实现,html 里的一些配置习惯用小驼峰,对于领域内有约定的场景,允许例外。
【正例】
// 符合:类名使用大驼峰
class MarcoPolo {
// CODE
}
// 符合:enum 类型和 enum 构造器使用大驼峰
enum ThreadState {
New | Runnable | Blocked | Terminated
}
// 符合:接口名使用大驼峰
interface TaPromotable {
// CODE
}
// 符合:类型别名使用大驼峰
type Point2D = (Float64, Float64)
// 符合:抽象类名使用大驼峰
abstract class AbstractAppContext {
// CODE
}
【反例】
// 不符合:类名使用小驼峰
class marcoPolos {
// CODE
}
// 不符合:enum 类型名使用小驼峰
enum timeUnit {
Year | Month | Day | Hour
}
函数
G.NAM.04 函数名称应采用小驼峰命名
【级别】建议
【描述】
-
函数名称采用小驼峰命名风格。例如,sendMessage 或 stopServer。
格式如下:
- 建议优先将 field 对外的接口实现成属性,而不是 getXXX/setXXX,会更简洁。
- 布尔属性名建议加 is 或 has, 例如:isEmpty。
- 函数名称建议使用以下格式:has + 名词 / 形容词 ()、动词 ()、动词 + 宾语 ()。
- 回调函数(callback)允许介词 + 动词形式命名,如: onCreate, onDestroy, toString 其中动词主要用在动作的对象自身上,如 document.print()。
-
下划线可能出现在单元测试函数名称中,用于分隔名称的逻辑组件,每个组件都使用小驼峰命名法。例如,一种典型的模式是
<methodUnderTest>_<state>
,又例如pop_emptyStack
,命名测试函数没有唯一的正确方法。
【正例】
// 符合:函数名使用小驼峰
func addExample(start: Int64, size: Int64) {
return start + size
}
// 符合:函数名使用小驼峰
func printAdd(add: (Int64, Int64) -> Int64): Unit {
println(add(1, 2))
}
【反例】
// 不符合:函数名使用大驼峰
func GenerateChildren(page: Int64) {
println(page.toString())
}
变量
G.NAM.05 const 变量的名称采用全大写
【级别】建议
【描述】
const 变量表示在编译时完成求值,并且在运行时不可改变的变量,使用下划线分隔的全大写单词来命名。
【反例】:
// 不符合 : const 变量没有使用下划线分隔的全大写单词命名
const MAXUSERNUM = 200
class Weight {
static const GramsPerKg = 1000
}
【正例】
// 符合 : const 变量使用下划线分隔的全大写单词命名
const MAX_USER_NUM = 200
class Weight {
static const GRAMS_PER_KG = 1000
}
G.NAM.06 变量的名称采用小驼峰
【级别】建议
【描述】
变量、属性、函数参数、pattern 等均采用小驼峰命名风格。
例外:
-
泛型类型变量,允许单个大写字母,或单个大写字母加数字,或单个大写字母接下划线、大写字母和数字的组合,例如 E, T, T2, E_IN, E_OUT, T_CONS
-
函数内使用的数值常量,不要使用魔鬼数字,用 let 声明有意义的局部变量代替,此时局部变量名可以使用全大写下划线的风格命名,强调是常量
不应该取 NUM_FIVE = 5 或 NUM_5 = 5 这样的 “魔鬼常量”。如果被粗心大意地改为 NUM_5 = 50 或 55 等,很容易出错。
【反例】:
// 不符合:变量名使用无意义单个字符
var i: Array<Item> = ...
// 不符合:类型参数使用小写字母
class Map<key, val> { ... }
【正例】
// 变量名使用小驼峰命名
let menuItems: Array<Item> = ...
let names: Array<String> = ...
let menuItemsArray: Array<Item> = ...
let menuItems: Set<Item> = ...
let rememberedSet: Set<Address> = ...
let waitingQue: Queue<Thread> = ...
let lookupTable: Array<Int64> = ...
let word2WordIdMap: Map<Stirng, Int> = ...
class MyPage <: Page {
var pageNo = StateInt64(1) // 实例成员变量使用小驼峰命名
var imagePath = StateArray(images) // 实例成员变量使用小驼峰命名
init() {
...
}
}
// 参数名使用小驼峰命名
func getColumnMoreDataColumn(pageType: String, idxColumn: Int64, outIndex: Int64) {
...
}
// 类型参数使用大写字母
class Map<KEY, VAL> { ... }
// 类型参数使用大写字母与数字
class Pair<T1, T2> { ... }
格式
尽管有些编程的排版风格因人而异,但是我们强烈建议和要求在同一个项目中使用统一的编码风格,以便所有人都能够轻松的阅读和理解代码,增强代码的可维护性。
编码格式
G.FMT.01 源文件编码格式(包括注释)必须是 UTF-8
【级别】要求
【描述】
对于源文件,应统一采用 UTF-8 进行编码。仓颉编译器目前仅支持 UTF-8 编码。
文件
G.FMT.02 一个源文件按顺序包含版权、package、import、顶层元素四类信息,且不同类别之间用空行分隔
【级别】建议
【描述】
一个源文件会包含以下几个可选的部分,应按顺序组织,且每个部分之间用空行隔开:
- 许可证或版权信息;
- package 声明,且不换行;
- import 声明,且每个 import 不换行;
- 顶层元素。
【正例】
// 第一部分,版权信息
/*
* Copyright (c) Huawei Technologies Co., Ltd. 2020-2020. All rights reserved.
*/
// 第二部分,package 声明
package com.huawei.myproduct.mymodule
// 第三部分,import 声明
import std.collection.HashMap // 标准库
// 第四部分,public 元素定义
public class ListItem <: Component {
// ...
}
// 第五部分,internal 元素定义
class Helper {
// CODE
}
G.FMT.03 import 包应该按照包所归属的组织或分类进行分组
【级别】建议
【描述】
说明:import 导入包根据归属组织或分类进行分组:本公司 (例如华为公司 com.huawei.),其它商业组织 (com.),其它开源第三方、net/org 开源组织、标准库。两个分组之间使用空行分隔。
【正例】
import com.huawei.* // 华为公司
import com.google.common.io.Files // 其它商业组织
import harmonyos.* // 开源
import mitmproxy.* // 其它开源第三方
import textual.* // 开源
import net.sf.json.* // 开源组织
import org.linux.apache.server.SoapServer // 开源组织
import std.io.* // 标准库
import std.socket.*
G.FMT.04 一个类、接口或 struct 的声明部分应当按照静态变量、实例变量、构造函数、成员函数的顺序出现,且用空行分隔
【级别】建议
【描述】
一个类或接口的声明部分应当按照以下顺序出现:
- 静态变量
- 实例变量
- 构造函数,如果有主构造函数,则主构造函数在其它构造函数之前
- 属性
- 成员函数
实例变量、构造函数,均按访问修饰符从大到小排列:public、protected、private。静态变量要按初始化顺序来,可以不遵循按访问修饰符从大到小排列的建议。
说明:
- 对于自注释字段之间可以不加空行;
- 非自注释字段应该加注释且字段间空行隔开;
- enum 没有成员变量,按照 enum 构造器,属性,成员函数的顺序出现;
行宽
G.FMT.05 行宽不超过 120 个窄字符
【级别】建议
【描述】
一个宽字符占用两个窄字符的宽度。除非另有说明,否则任何超出此限制的行都应该换行,如 [换行](# 换行) 一节中所述。
每个 Unicode 代码点都计为一个字符,即使其显示宽度大于或小于一个字符。如果使用 全角字符,可以选择比此规则建议的位置更早地换行。
字符的 “宽” 与“窄”由它的 east asian width Unicode 属性 定义。通常,窄字符也称 “半角” 字符,ASCII 字符集中的所有字符,包括字母(如:a
、A
)、数字(如:0
、3
)、标点(如',
'、'{
')、空格,都是窄字符;
宽字符也称 “全角” 字符,汉字(如:中
、文
)、中文标点(','
、'、
')、全角字母和数字(如 A
、3
)等都是宽字符,算 2 个窄字符。
例外:
package
和import
声明;- 对于换行导致内容截断,不方便查找、拷贝的字符(如长 URL、命令行等)可以不换行。
换行
换行是将代码分隔到多个行的行为,否则它们都会堆积到同一行。
如需换行,建议在以下位置进行换行:
- 函数参数列表(包括形参和实参),两个参数之间,在逗号后换行;
- 函数返回类型,在
:
后换行; - 泛型参数列表(包括泛型形参和泛型实参),两个泛型参数之间,在逗号后换行;
- 泛型约束条件列表,两个约束条件之间,在
&
或逗号后换行; - 类型声明时,父类和实现接口列表,在
&
后换行; - lambda 表达式的
=>
后换行; - 如果需要在二元操作符
+
、-
号的位置换行,应在操作符之后换行以避免歧义;
举个例子,下面这个复杂的函数声明,建议在箭头(^
)所指的位置进行换行:
public func codeStyleDemo<T, M>(param1: String, param2: String): String where T <: ToString, M <: ToString { ... }
// ^ ^ ^ ^
G.FMT.06 表达式中不要插入空白行
【级别】建议
【描述】
- 当表达式过长,或者可读性不佳时,需要在合适的地方换行。表达式换行后的缩进要跟上一行的表达式开头对齐。
- 可以在表达式中间插入注释行
- 不要在表达式中间插入空白行
【正例】
func foo(s: String) {
s
}
func bar(s: String) {
s
}
/* 符合,表达式中可以插入注释进行说明 */
main() {
let s = "Hello world"
/* this is a comment */
|> foo
|> bar
}
【反例】
func foo(s: String) {
s
}
func bar(s: String) {
s
}
/* 不符合,表达式中不应插入空白行 */
main() {
let s = "Hello world"
|> foo
|> bar
}
G.FMT.07 一行只有一个声明或表达式
【级别】建议
【描述】声明或表达式应该单独占一行,更加利于阅读和理解代码。
【反例】
func foo() {
// 不符合:多个变量声明需要分开放在多行
var length = 0; var result = false
// 不符合: 多个表达式需分开放在多行
result = true; result
}
【正例】
func foo() {
// 符合:多个变量声明需要分开放在多行
var length = 0
var result = false
// 符合: 多个表达式分开放在多行
result = true
result
}
缩进
G.FMT.08 采用一致的空格缩进
【级别】建议
【描述】
建议使用空格进行缩进,每次缩进 4 个空格。避免使用制表符(\t
)进行缩进。
对于 UI 等多层嵌套使用较多的产品,可以统一使用 2 个空格缩进。
【正例】
class ListItem {
var content: Array<Int64> // 符合:相对类声明缩进 4 个空格
init(
content: Array<Int64>, // 符合:函数参数相对函数声明缩进 4 个空格
isShow!: Bool = true,
id!: String = ""
) {
this.content = content
}
}
大括号
G.FMT.09 使用统一的大括号换行风格
【级别】建议
【描述】
选择并统一使用一种大括号换行风格,避免多种风格并存。
对于非空块状结构,大括号推荐使用 K&R 风格:
- 左大括号不换行;
- 右大括号独占一行,除非后面跟着同一表达式的剩余部分,如
do-while
表达式中的while
,或者if
表达式中的else
和else if
等。
【正例】
enum TimeUnit { // 符合:跟随声明放行末,前置 1 空格
Year | Month | Day | Hour
} // 符合:右大括号独占一行
class A { // 符合:跟随声明放行末,前置 1 空格
var count = 1
}
func fn(a: Int64): Unit { // 符合:跟随声明放行末,前置 1 空格
if (a > 0) { // 符合:跟随声明放行末,前置 1 空格
// CODE
} else { // 符合:右大括号和 else 在同一行
// CODE
} // 符合:右大括号独占一行
}
// lambda 函数
let add = { base: Int64, bonus: Int64 => // 符合: lambda 表达式中非空块遵循 K&R 风格
print("符合 news")
base + bonus
}
【反例】
func fn(count: Int64)
{ // 不符合:左大括号不应该单独一行
if (count > 0)
{ // 不符合:左大括号不应该单独一行
// CODE
} // 不符合:右大括号后面还有跟随的 else,应该和 else 放同一行
else {
print("count <= 0")} // 不符合:右大括号后面没有跟随的部分,应该独占一行
}
例外: 对于空块既可遵循前面的 K&R 风格,也可以在大括号打开后立即关闭,产品应考虑使用统一的风格。
【正例】
open class Demo {} // 符合: 空块,左右大括号在同一行
空行和水平空格
G.FMT.10 用空格突出关键字和重要信息
【级别】建议
【描述】
水平空格应该突出关键字和重要信息。单个空格应该分隔关键字与其后的左括号、与其前面的右大括号,出现在二元操作符 / 类似操作符的两侧。行尾和空行不应用空格 space。总体规则如下:
-
建议 加空格的场景:
- 条件表达式(if 表达式),循环表达式(for-in 表达式,while 表达式和 do-while 表达式),模式匹配表达式(match 表达式)和 try 表达式中关键字与其后的左括号,或与其前面的右括号之间
- 赋值运算符(包括复合)前后,例如
=
、*=
等 - 逗号
,
、enum 定义中的|
符号、变量声明 / 函数定义 / 命名参数传值中的冒号:
之后,例如let a: Int64
- 二元操作符、泛型约束的
&
符号、声明父类父接口或实现 / 扩展接口的<:
符号、range
操作符步长的冒号:
前后两侧,例如base + offset
,Int64 * Int64
等 - lambda 表达式中的箭头前后,例如
{str => str.length()}
- 条件表达式、循环表达式等场景下的
)
与{
之间加空格,例如:if (i > 0) {
。 - 函数、类型等声明的
{
之前加空格,例如:class A {
-
不建议 加空格的场景:
- 成员访问操作符(
instance.member
)前后, 问号操作符?
前后 range
操作符(0..num
、0..=num
这 2 种区间)前后- 圆括号、方括号内两侧
- 一元操作符前后,例如
cnt++
- 函数声明或者函数调用的左括号之前
- 逗号
,
和变量声明 / 函数定义 / 命名参数传值中的冒号:
之前 - 下标访问表达式中,
[
和它之前的 token 之间
- 成员访问操作符(
推荐示例如下:
var isPresent: Bool = false // 符合:变量声明冒号之后有一个空格
func method(isEmpty!: Bool):Unit { /* ... */ } // 符合:函数定义(命名参数 / 返回类型)中的冒号之后有一个空格
func test() {
method(isEmpty: isPresent) // 符合: 命名参数传值中的冒号之后有一个空格
const MAX_COUNT = 100
0..MAX_COUNT : -1 // 符合: range 操作符区间前后没有空格,步长冒号前后两侧有一个空格
var hundred = 0
do { // 符合:关键字 do 和后面的括号之间有一个空格
hundred++ // 符合:一元操作符和操作数之间不留空格
} while (hundred < 100) // 符合:关键字 while 和前面的括号之间有一个空格
let listOne: Array<Int64> = [1, 2, 3, 4] // 符合:方括号和圆括号内部两侧不出现空格
let (base, bonus) = (1, 2)
let salary = base + bonus // 符合:二元操作符左右两侧留空格
}
func fn(paramName1: Int, paramName2: Int) { // 符合:圆括号和内部相邻字符之间不出现空格
//...
for (i in 1..4) { // 符合:range 操作符左右两侧不留空格
//...
}
}
G.FMT.11 减少不必要的空行,保持代码紧凑
【级别】建议
【描述】
减少不必要的空行,可以显示更多的代码,方便代码阅读。建议:
- 根据上下内容的相关程度,合理安排空行;
- 类型定义和顶层函数定义与前后顶层元素之间至少空一行;
- 函数内部、类型定义内部、表达式内部,不使用连续空行;
- 不使用连续 3 个或更多空行;
- 大括号内的代码块行首之前和行尾之后不要加空行。
【反例】
class MyApp <: App {
let album = albumCreate()
let page: Router
// 空行
// 空行
// 空行
init() { // 不符合:类型定义内部使用连续空行
this.page = Router("album", album)
}
override func onCreate(): Unit {
println( "album Init." ) // 不符合:大括号内部首尾存在空行
}
}
修饰符
G.FMT.12 修饰符关键字按照一定优先级排列
【级别】建议
【描述】
以下是推荐的所有修饰符排列的优先级:
public/protected/private
open/abstract/static/sealed
override/redef
unsafe/foreign
const/mut
另外,因为 sealed 已经蕴含了 public 和 open 的语义,不推荐 sealed 与 public 或 open 同时使用。
注释
注释是为了帮助阅读者快速读懂代码,所以要从读者的角度出发,按需注释。
注释内容要简洁、明了、无二义性,信息全面且不冗余。
注释跟代码一样重要。
CangJie 中注释的语法有以下几种:单行注释(//
)、多行注释(/*...*/
)。本节介绍如何规范使用这些注释。
文件头注释
G.FMT.13 文件头注释应该包含版权许可
【级别】建议
【描述】
文件头注释必须放在 package 和 import 之前,必须包含版权许可信息,如果需要在文件头注释中增加其他内容,可以在后面以相同格式补充。版本许可不应该使用单行样式的注释,必须从文件顶头开始。如果包含 “关键资产说明 “类注释,则应紧随其后。
版权许可内容及格式必须如下:
中文版:
/*
* 版权所有 (c) 华为技术有限公司 2019-2021
*/
英文版:
/*
* Copyright (c) Huawei Technologies Co., Ltd. 2019-2021. All rights reserved.
*/
关于版本说明,应注意:
-
2019-2021 根据实际需要可以修改。
2019 是文件首次创建年份,而 2021 是文件修改最后年份。二者可以一样,如 "2021-2021"。
对文件有重大修改时,必须更新后面年份,如特性扩展,重大重构等。
-
版权说明可以使用华为子公司。
如:版权所有 (c) 海思半导体 2012-2020
或英文: Copyright (c) Hisilicon Technologies Co., Ltd. 2019-2021. All rights reserved.
代码注释
G.FMT.14 代码注释放于对应代码的上方或右边
【级别】建议
【描述】
-
代码上方的注释与被注释的代码行间无空行,保持与代码一样的缩进。
-
代码右边的注释,与代码之间至少留有 1 个空格。代码右边的注释,无需插入空格使其强行对齐。
选择并统一使用如下风格:
var foo = 100 // 放右边的注释 var bar = 200 /* 放右边的注释 */
-
当右置的注释超过行宽时,请考虑将注释至于代码上方。同一个 block 中的代码注释不用插入空格使其强行对齐。
【正例】
class Column <: Div { var reverse: Bool = false var justifyContent: JustifyContent = JustifyContent.flexStart var alignItems: AlignItems = AlignItems.stretch // 注释和代码间留一个空格 var alignSelf: AlignSelf = AlignSelf.auto // 上下两行注释无需插入空格强行对齐 init() { ... } }
-
if else if
为了更清晰,考虑注释放在else if
同行或者在块内都行,但不是在else if
之前,避免以为注释是关于它所在块的。【反例】
func test() { var nr = 100 if (nr % 15 == 0) { println("fizzbuzz") // 当 nr 只能被 3 整除,不能被 5 整除不符合。 } else if (nr % 3 == 0) { println("fizz") } }
上述错误示例的注释是
if
分支的还是else if
分支的,容易造成误解。【正例】
func test() { var nr = 100 if (nr % 15 == 0) { println("fizzbuzz") } else if (nr % 3 == 0) { // 当 nr 只能被 3 整除,不能被 5 整除不符合。 println("fizz") } }
编程实践
声明
G.DCL.01 避免遮盖(shadow)
【级别】建议
【描述】
由于变量、类型、函数、包名共享一个命名空间,这几类实体之间重用名字会发生遮盖 (shadow),因此,需尽量避免无关的实体之间发生遮盖 (shadow);
否则,这种源代码上的模糊性,会给维护者和检视者带来很大的困扰和负担。尤其是当代码既需要访问复用的标识符,又需要访问被覆盖的标识符时。当复用的标识符在不同的包里时,这个负担会变得更加沉重。
【反例】
main(): Unit {
var name = ""
var fn = {=>
var name = "Zhang" // Shadow
println(name)
}
println(name) // prints ""
fn() // prints "Zhang"
}
类似地,类型参数名称也要尽量避免遮盖。
【反例】
class Foo<T> {
static func foo<T>(a: T): T { return a }
func goo(a: T): T { return a }
}
上面代码中静态泛型函数的类型参数 T
遮盖了泛型类的类型参数 T
。
函数
G.FUN.01 函数功能要单一
【级别】建议
【描述】
过长的函数往往意味着函数功能不单一,可以进行进一步拆分或分层。过于复杂的函数不利于阅读理解,难以维护。
可以考虑从以下维度间接衡量函数功能是否单一:
-
函数行数,建议不超过 50 行(非空非注释);
-
除构造函数外,函数的参数,建议不超过 5 个;
-
函数最大代码块嵌套不要过深,建议不要超过 4 层。函数的代码块嵌套深度指的是函数中的代码控制块(例如:if、for、while、match 等)之间互相包含的深度。
G.FUN.02 禁止函数有未被使用的参数
【级别】要求
【描述】
未被使用的参数往往是因为设计发生了变动造成的,它可能导致传参时出现不正确的参数匹配。
【反例】
func logInfo(fileName: String, lineNo: Int64): Unit {
println(fileName)
}
例外:回调函数和 interface 实现等情形,可以用_
代替未被使用的参数。
interface I {
func f(cfg: String) {
println(cfg)
}
}
class DefaultImpl <: I {
func f(_: String) {
println("default")
}
}
G.FUN.03 避免在无关的函数之间重用名字,构成重载
【级别】建议
【描述】
函数重载的主要作用是使用同一个函数名来接受不同的参数实现相似的任务,为了代码的可读性和可维护性,应尽量避免完成不同任务的函数之间构成重载 (overload)。如果多个函数之间有必要构成重载,那么应满足以下要求:
- 它们应在同一个类型或文件内依次定义,避免重载的多个函数出现在不同作用域层级;
- 构成重载的函数之间应尽量避免同一组实参能通过多个函数的类型检查。
以上对函数重载的建议,指的是一个团队内部自定义的函数之间构成重载的建议,以下情形可以例外:
- 如果和第三方或标准库中的函数之间有必要构成重载,可以出现在不同包;
- 如果有必要进行操作符重载,操作符重载函数可以出现在不同文件或不同包;
- 父类和子类的函数之间如果有必要成重载,可以出现在不同文件或包。
【反例】
以下例子中,在 package a
定义了函数 fn(a: Derived)
,在 package b
定义了 fn(a: Base)
的重载函数,由于两个重载函数在不同的作用域层级,导致在 package b
中调用 fn
时,根据作用域优先级原则,选择不是最匹配的 fn(a: Base)
。
另一个不符合规范的例子是,两个构成重载的函数 g
的对应位置参数类型为被实例化的关系或父子类型关系,函数调用时两个函数均通过类型检查,但根据最匹配原则,没有最匹配函数,导致无法决议。
package a
public open class Base {
...
}
public class Derived <: Base {
...
}
public func fn(a: Derived) {
...
}
////
package b
import a.Base
import a.Derived
import a.fn
func fn(a: Base) { // 不符合:两个 fn 在不同的作用域层级
...
}
main() {
fn(Derived()) // 根据作用域优先级原则,调用的是 fn(a: Base)
g(Derived(), Derived()) // 根据最匹配原则,没有最匹配函数,无法决议
}
// 不符合: 两个 g 的对应位置参数类型为被实例化的关系或父子类型关系,很容易构造实参让两个均通过类型检查
func g<X>(a: X, b: Derived) {
...
}
func g(a: Derived, b: Base) {
...
}
【正例】
public open class Base {
// CODE
}
public class Derived <: Base {
// CODE
}
// 符合:构成重载的函数在同一层作用域内依次出现,且参数之间不存在子类型或被实例化的关系
func fn(a: Base) {
// CODE
}
func fn(a: Int64) {
// CODE
}
类
G.CLS.01 override 父类函数时不要增加函数的可访问性
【级别】建议
【描述】
增加 override 函数的可访问性,子类将拥有比预期更大的访问权限。
【反例】
open class Base {
protected open func f(a: Int64): Int64 {
return a
}
}
class Sub <: Base {
public override func f(a: Int64): Int64 {
super.f(a)
//do some sensitive operations
}
public func g(a: Int64): Int64 {
super.f(a) // 这种也算是增加可访问性。
}
}
上面的错误代码中,子类 override 了基类的 f()
函数,并增加了函数的可访问性。基类 Base
定义的 f()
函数为 protected
的,子类 Sub
定义该函数为 public
的,从而增加了 f()
的访问性。因此,任何 Sub
的使用者都可以调用此函数。
【正例】
open class Base {
protected open func f(a: Int64): Int64 {
//do some sensitive operations
return a
}
}
class Sub <: Base {
protected override func f(a: Int64): Int64 {
return a + 1
}
}
该正确示例中,子类覆写的基类 f()
函数与基类保持一致为 protected
。
G.CLS.02 创建对象优先使用构造函数,慎用静态工厂方法
【级别】建议
【描述】
静态工厂方法是一种将对象的构造与使用相分离的设计模式,它使用一个(或一系列)静态函数代替构造函数来构造数据。
在仓颉中应优先使用构造函数来构造对象,除了下文提到的例外情况,尽量避免使用静态工厂方法。
首先,若必须使用,为了方便识别,静态工厂方法的名称应包含 from, of, valueOf, instance, get, new, create 等常用关键字来突出构造作用。 如果为了区别无法仅从参数中识别的多种构造方法,各个静态工厂函数的名称也应有所区别以解释具体的构造方式。
仓颉语言支持命名参数和操作符重载,在只用作区分构造方式的目的时,比起静态工厂方法, 应优先考虑带命名参数的构造函数、单位常量乘以数目等更直观的构造方式。在这些方法都难以理解时再考虑静态工厂方法。
【反例】
class Weight {
private static const G_PER_KG = 1000.0
let g: Int64
Weight(gram: Int64, kilo: Float64) {
g = gram + Int64(kilo * G_PER_KG)
}
init(gram: Int64) {
this(gram, 0.0)
}
init(kilo: Float64) {
this(0, kilo)
}
}
// 不符合,难以区分
main() {
let w1 = Weight(1)
let w2 = Weight(0.5)
let w3 = Weight(1, 2.0)
}
class Weight {
private static const G_PER_KG = 1000.0
private Weight(let g: Int64) {}
static func ofGram(gram: Int64) {
Weight(gram)
}
static func ofKilo(kilo: Float64) {
Weight(Int64(kilo * G_PER_KG))
}
static func ofGramNKilo(gram: Int64, kilo: Float64) {
Weight(gram + Int64(kilo * G_PER_KG))
}
}
// 不符合,有更直观的替代方式
main() {
let w1 = Weight.ofGram(1)
let w2 = Weight.ofKilo(0.5)
let w3 = Weight.ofGramNKilo(1, 2.0)
}
【正例】
class Weight {
private static const G_PER_KG = 1000.0
let g: Int64
Weight(gram!: Int64, kilo!: Float64) {
g = gram + Int64(kilo * G_PER_KG)
}
init(gram!: Int64) {
this(gram: gram, kilo: 0.0)
}
init(kilo!: Float64) {
this(gram: 0, kilo: kilo)
}
}
/* 符合,优先使用 */
main() {
let w1 = Weight(gram: 1)
let w2 = Weight(kilo: 0.5)
let w3 = Weight(gram: 1, kilo: 2.0)
}
class Weight {
private static const G_PER_KG = 1000
private Weight(let g: Int64) {}
static let gram = Weight(1)
static let kilo = Weight(G_PER_KG)
operator func *(rhs: Int64) {
Weight(g * rhs)
}
operator func *(rhs: Float64) {
Weight(Int64(Float64(g) * rhs))
}
operator func +(rhs: Weight) {
Weight(g + rhs.g)
}
}
extend Int64 {
operator func *(rhs: Weight) {
rhs * this
}
}
extend Float64 {
operator func *(rhs: Weight) {
rhs * this
}
}
/* 符合,优先使用 */
main() {
let w1 = 1 * Weight.gram
let w2 = 0.5 * Weight.kilo
let w3 = 1 * Weight.kilo + 2 * Weight.gram
}
import std.random.Random
class BigInteger {
/* ....... */
static func probablePrime(bitWidth: Int64, rnd: Random) {
/* some complex computation */
/* ............ */
BigInteger()
}
}
main() {
// 符合,较为复杂,仅用命名参数难以解释清楚
let rndPrime = BigInteger.probablePrime(16, Random())
}
另一种允许使用静态工厂方法的情况是在需要获得缓存的对象时,构造函数总是会构造新的对象,此时可以使用静态工厂方法来达到访问缓存的目的。 一些典型的情况包括:不可变类型、与资源绑定的类型、构造过程非常耗时的类型等
【正例】
import std.collection.HashMap
class ImmutableData {
private static let cache = HashMap<String, ImmutableData>()
// 符合,返回缓存的不可变对象
public static func getByName(name: String) {
if (cache.contains(name)) {
return cache[name]
} else {
cache[name] = ImmutableData(name)
return cache[name]
}
}
private ImmutableData(name: String) {
// some very time-consuming process
}
}
main() {
let d1 = ImmutableData.getByName("abc") // new
let d2 = ImmutableData.getByName("abc") // cached
}
最后一种情形是返回接口的实例从而将接口与实现类解耦,达到隐藏实现细节或者按需替换实现类的目的。
【正例】
sealed interface I1 {
// 符合,隐藏实现细节
static func getInstance() {
return C1() as I1
}
func Safe():Unit
}
interface I2 <: I1 {
func Safe():Unit
func Secret():Unit
}
class C1 <: I2 {
public func Safe():Unit {}
public func Secret():Unit {}
}
sealed interface I1 {
// 符合,根据输入选择实现方式
static func fromInt(i: Int64) {
if (i > 100) {
return ImplForLarge() as I1
} else {
return ImplForSmall() as I1
}
}
func foo():Unit
}
class ImplForLarge <: I1 {
public func foo():Unit {}
}
class ImplForSmall <: I1 {
public func foo():Unit {}
}
接口
G.ITF.01 对于需要原地修改对象自身的抽象函数,尽量使用 mut 修饰,以支持 struct 类型实现或扩展该接口
【级别】建议
【描述】
说明: 如果对于可能需要原地修改的函数不声明为 mut 函数,未来就不能被 struct 类型实现,会导致接口的抽象能力降低。
【正例】
interface Increasable {
mut func increase(): Unit
}
struct R <: Increasable {
var item = 0
public mut func increase(): Unit {
item += 1
}
}
【反例】
interface Increasable {
func increase(): Unit // 不符合:struct 类型实现该接口时,无法实际被修改
}
struct R <: Increasable {
var item = 0
public func increase(): Unit {
item += 1 // item 不能被实际修改
}
}
G.ITF.02 尽量在类型定义处就实现接口,而不是通过扩展实现接口
【级别】建议
【描述】
说明:
- 通过扩展实现接口不应该被滥用,如果一个类型在定义时就已知将要实现的接口信息,应该将接口直接声明出来,有利于使用者集中浏览信息。
- 通过扩展实现的接口,和类型定义处声明实现接口,在实现层面可能带来协变层面的问题。这个开发者可能不容易感知,但尽量在定义处声明实现接口,可以有效避免。
【反例】
interface I {
func f(): Unit
}
class C {}
extend C <: I {
public func f(): Unit {}
}
main() {
let i: I = C() // ok
let f1: () -> C = { => C() }
let f2: () -> I = f1 // 报错,虽然 () -> C 是 () -> I 的子类型,但 C 通过扩展实现 I,此时不能协变,导致不能赋值。
return 0
}
【正例】
interface I {
func f(): Unit
}
// 符合:类型定义处实现接口
class A <: I {
public func f(): Unit {
// CODE
}
}
G.ITF.03 类型定义时避免同时声明实现父接口和子接口
【级别】建议
【描述】
同时实现父接口和子接口时,父接口属于冗余信息,对用户甄别信息造成困扰。避免声明重复的父接口可以让声明保持简洁。
interface Base {
func f1(): Unit
}
interface Sub <: Base {
func f2(): Unit
}
// 符合
class A <: Sub {
public func f1(): Unit {
// CODE
}
public func f2(): Unit {
// CODE
}
}
// 不符合
class B <: Sub & Base {
public func f1(): Unit {
// CODE
}
public func f2(): Unit {
// CODE
}
}
G.ITF.04 尽量通过泛型约束使用接口,而不是直接将接口作为类型使用
【级别】建议
【描述】
class 以外的类型转型到 interface 可能会附带装箱操作,而作为泛型约束的方式使用 interface 可以直接静态派发,避免装箱和动态派发带来的开销,提升性能。
interface I {
func f(): Unit
}
// 符合
func g<T>(i: T): Unit where T <: I {
return i.f()
}
// 不符合
func g(i: I): Unit {
return i.f()
}
操作符重载
G.OPR.01 尽量避免违反使用习惯的操作符重载
【级别】建议
【描述】
重载操作符时要有充分的理由,尽量避免改变操作符的原有使用习惯,例如使用 +
操作符来做减法运算,避免对基础类型重载已内置支持的操作符。
【正例】:
struct Point {
Point(let x: Int64, let y: Int64) {
}
operator func +(rhs: Point): Point { // 符合:为 Point 重载加法操作符
return Point(this.x + rhs.x, this.y + rhs.y)
}
}
【反例】
struct Point {
Point(let x: Int64, let y: Int64) {
}
// 不符合:为 Point 重载加法操作符,但其实成员间做的是减法操作
operator func +(rhs: Point): Point {
return Point(this.x - rhs.x, this.y - rhs.y)
}
}
extend Int64 {
operator func +(right: Float64) { // 不符合:对基础类型重载已内置支持的操作符
// CODE
}
}
G.OPR.02 尽量避免在 enum 类型内定义 ()
操作符重载函数
【级别】建议
【描述】
enum 类型中定义 ()
操作符重载函数,可能会和构造成员造成冲突,当两者之间发生冲突将优先调用 enum 类型的构造成员。因此建议尽量避免在 enum 类型中定义 ()
操作符重载函数。
【反例】
enum E {
Y | X | X(Int64)
operator func ()(a: Int64) { // 不符合: enum 类型内定义 () 操作符重载函数,且与构造器有冲突
// CODE
}
}
let e = X(1) // 调用的是 enum 构造器:X(Int64).
enum
G.ENU.01:避免 enum 的构造器与顶层元素同名
【级别】要求
【描述】
enum 构造器名字在类型所在作用域下总是自动引入,可以省略类型前缀使用。 但是当 enum 构造器与变量名、函数名、类型名、包名冲突的时候,会优先选择变量名、函数名、类型名或包名,不容易发现冲突,也难以直观看出实际使用的版本。 所以应尽量保证 enum 的构造器与顶层函数使用不同的名字,以避免不必要的重载所带来的困惑。
【正例】:
enum TimeUnit {
| Year(Int64)
| Month(Int64, Int64)
| Day(Int64, Int64, Int64)
}
class MyYear {
let a: Int64
init(a: Int64) {
this.a = a
}
}
main() {
let y1 = Year(100) // ok,Year(100) 调用的是 TimeUnit 中的 Year(Int64) 构造器
let y2 = MyYear(100) // ok,调用的是 class MyYear 的构造函数
return 0
}
【反例】
enum TimeUnit {
| Year(Int64) // 不符合:enum 构成成员与顶层的 class 类型同名
| Month(Int64, Int64)
| Day(Int64, Int64, Int64)
}
class Year {
Year(let a: Int64) {
}
}
main() {
let y = Year(100) // 实际使用的是 class Year 的构造函数
return 0
}
【反例】
enum E {
| f1(Int64) // 不符合:enum 构成成员与顶层的函数同名
| f2(Int64, Int64)
}
func f1(a: Int64) {}
func f2(a: Int64, b: Int64) {}
main() {
f1(1) // 实际使用的是 func f1
f2(1, 2) // 实际使用的是 func f2
return 0
}
G.ENU.02 尽量避免不同 enum 构造器之间不必要的重载
【级别】建议
【描述】
因为 enum 构造器的名字在类型所在作用域下总是自动引入的,所以不同 enum 中定义同名且对应位置参数类型存在子类型关系的构造成员后,省略类型前缀的使用方式将不再可用。enum 构造器参与函数的重载决议,当无法决议时 enum 构造器和函数均不能直接使用,此时 enum 构造器需要使用类型前缀的方式使用,函数也需要通过前缀限定的方式使用。只要有多个 enum constructor 通过类型检查,或只要有 enum constructor 和函数同时通过类型检查,就会造成无法决议。
【正例】
enum TimeUnit1 {
| Year1(Int64)
| Month1(Int64, Int64)
| Day1(Int64, Int64, Int64)
}
enum TimeUnit2 {
| Year2(Int64)
| Month2(Int64, Int64)
| Day2(Int64, Int64, Int64)
}
main() {
let a = Year1(1) // ok:无需使用 enum 类型前缀
let b = Year2(2) // ok:无需使用 enum 类型前缀
return 0
}
【反例】
enum TimeUnit1 {
| Year(Int64)
| Month(Int64, Int64)
| Day(Int64, Int64, Int64)
}
enum TimeUnit2 {
| Year(Int64)
| Month(Int64, Int64)
| Day(Int64, Int64, Int64)
}
main() {
let a = Year(1) // error:无法决议调用的是哪个 Year(Int64)
let b = TimeUnit1.Year(1) // ok:使用 enum 类型前缀
let c = TimeUnit2.Year(2) // ok:使用 enum 类型前缀
return 0
}
【正例】
open class Base {}
class Derived <: Base {}
enum E1 {
| A1(Base)
}
enum E2 {
| A2(Derived)
}
main() {
let a1 = A1(Derived()) // ok:无需使用 enum 类型前缀
let a2 = A2(Derived()) // ok:无需使用 enum 类型前缀
return 0
}
【反例】
open class Base {}
class Derived <: Base {}
enum E1 {
| A(Base)
}
enum E2 {
| A(Derived)
}
main() {
let a = A(Derived()) // error:无法决议调用的是哪个 enum 中的 constructor
let a2 = E1.A(Derived()) // ok:使用 enum 类型前缀
let a3 = E2.A(Derived()) // ok:使用 enum 类型前缀
return 0
}
变量
G.VAR.01 优先使用不可变变量
【级别】建议
【描述】初始化后未修改的变量或属性,建议将其声明为 let
而不是 var
。
G.VAR.02 保持变量的作用域尽可能小
【级别】建议
【描述】
作用域是指变量在程序内可见和可引用的范围,这个范围越大引起错误的可能性越高,对它的可控制性就越差。
例如:在变量的可见范围内新增代码,不当地修改了这个变量可能引发错误;如果可见范围过大,阅读代码的人也可能会忘记该变量应有的值,可读性差。
所以,通常应使变量的作用域尽量小,同时把变量引用点尽量集中在一起,便于对变量施加控制。
最小化作用域,可以增强代码的可读性和可维护性,并降低出错的可能性。
最小化作用域的函数:
- 尽量推迟变量定义,在变量使用时才声明并初始化
- 把相关声明和表达式放在一起或提取成单独的子函数,使函数尽量小而集中,功能单一。
G.VAR.03 避免使用全局变量
【级别】建议
【描述】
使用全局变量会导致业务代码和全局变量之间产生数据耦合,并且很难跟踪数据的变化,建议避免使用全局变量。使用全局常量通常是必要的,例如定义一些全局使用的数值。
数据类型
G.TYP.01 确保以正确的策略处理除数
【级别】建议
【描述】
在除法运算和模运算中,可能会发生除数为 0 的错误。对于整数运算,仓颉在运行时会自动检查除数,当除数为 0 时会自动抛出异常。不处理除零的情况可能会导致程序终止或拒绝服务(DoS)。捕获除零异常可能会导致性能开销较高,存在多个除法操作的时候会导致难以排查异常抛出点,因此开发者需要显式地对除数进行判断。
【反例】
func f() {
var num1: Int64
var num2: Int64
var result: Int64
// Initialize num1 and num2
...
result = num1 / num2
}
上面的示例中,有符号操作数 num1 和 num2 的除法运算,num2 可能为 0,导致除 0 错误的发生。
【正例】
func f() {
var num1: Int64
var num2: Int64
var result: Int64
// Initialize num1 and num2
...
if (num2 == 0) {
//Handle error
} else {
result = num1 / num2
}
}
该正确示例中,对除数进行了检查,从而杜绝了发生除 0 错误的发生。
【反例】
func f() {
var num1: Int64
var num2: Int64
var result: Int64
// Initialize num1 and num2
...
result = num1 % num2
}
整数类型的操作数,模运算符会计算除法中的余数。上述不符合规则的代码示例,在进行模运算时,可能会因为 num2 为 0 导致除 0 错误的发生。
【正例】
func f() {
var num1: Int64
var num2: Int64
var result: Int64
// Initialize num1 and num2
...
if (num2 == 0) {
//Handle error
} else {
result = num1 % num2
}
}
该正确示例中,对除数进行了检查,从而杜绝了除 0 错误的发生。
G.TYP.02 确保正确使用整数运算溢出策略
【级别】要求
【描述】
仓颉中提供三种属性宏来控制整数溢出的处理策略,@OverflowThrowing,@OverflowWrapping 和 @OverflowSaturating 分别对应抛出异常、高位截断以及饱和这三种溢出处理策略,默认情况下(即未使用宏),采取抛出异常的处理策略 。
实际情况下需要根据业务场景的需求正确选择溢出策略。例如要在 Int64 上实现某种安全运算,使得计算结果和计算过程在数学上相等,就需要使用抛出异常的策略。
【反例】
// 计算结果被高位截断
@OverflowWrapping
func operation(a: Int64, b: Int64): Int64 {
a + b // No exception will be thrown when overflow occurs
}
该错误例子使用了高位截断的溢出策略,当传入的参数 a 和 b 太大时,可能产生高位截断的情况,导致计算结果和计算表达式 (a + b) 在数学上不是相等关系。
【正例】
// 安全
@OverflowThrowing
func operation(a: Int32, b: Int32): Int32 {
a + b
}
func test(a: Int32, b: Int32) {
try {
let v = operation(a, b)
} catch (e: ArithmeticException) {
//Handle error
}
}
该正确例子使用了抛出异常的溢出策略,当传入的参数 a 和 b 较大导致整数溢出时,operation 函数会抛出异常。
附录 B 总结了可能造成整数溢出的数学操作符。
表达式
G.EXP.01 match 表达式同一层尽量避免不同类别的 pattern 混用
【级别】建议
【描述】
仓颉提供了丰富的模式种类,包括:常量模式、通配符模式、变量模式、tuple 模式、类型模式、enum 模式。在类型匹配的前提下,根据是否总是能匹配分为两种:refutable pattern 和 irrefutable pattern,其中 irrefutable pattern 总是可以和它所要匹配的值匹配成功。
对 pattern 的使用建议如下:
- match 表达式的不同 case 的 pattern 之间尽量保持互斥,避免依赖匹配顺序;
- match 不能互斥时,由于匹配的顺序是从前往后,要避免前面的 case 遮盖后面的 case,比如 irrefutable pattern 的 case 需要放到所有 refutable pattern 的 case 之后;
- match 表达式同一层中尽量避免混用不同判断维度的模式:
- 类型模式和其它判断的维度也不一样,比如常量模式是根据值来判断,类型模式是判断类型,混用后对 exhaustive 的可读性会有影响;
- tuple 模式、enum 模式属于解构,可以和常量模式、变量模式结合使用。
【反例】
enum TimeUnit {
| Year(Int64)
| Month(Int64, Int64)
| Day(Int64, Int64, Int64)
| Hour(Int64, Int64, Int64, Int64)
}
let oneYear = Year(1)
let howManyHours = match (oneYear) { // 不符合:enum 模式、类型模式混用
case Month(y, m) => ...
case _: TimeUnit => ...
case Day(y, m, d) => ...
case Hour(y, m, d, h) => ...
}
【正例】:
enum TimeUnit {
| Year(Int64)
| Month(Int64, Int64)
| Day(Int64, Int64, Int64)
| Hour(Int64, Int64, Int64, Int64)
}
let oneYear = Year(1)
let howManyHours = match (oneYear) {
case Year(y) => //...
case Month(y, m) => //...
case Day(y, m, d) => //...
case Hour(y, m, d, h) => //...
}
G.EXP.02 不要期望浮点运算得到精确的值
【级别】建议
【描述】
因为存储二进制浮点的 bit 位是有限的,所以二进制浮点数的表示范围也是有限的,并且无法精确地表示所有实数。因此,浮点数计算结果也不是精确值,除了可以表示为 2 的幂次以及整数数乘的浮点数可以准确表示外,其余数的值都是近似值。
实际编程中,要结合场景需求,尤其是对精度的要求,合理选择浮点数操作。
例如,对于浮点值比较,如果对比较精度有要求,通常不建议直接用 != 或 == 比较,而是要考虑对精度的要求。
【正例】
import std.math.*
func isEqual(a: Float64, b: Float64): Bool {
return abs(a - b) <= 1e-6
}
func compare(x: Float64) {
if (isEqual(x, 3.14)) {
// CODE
} else {
// CODE
}
}
【反例】
func compare(x: Float64) {
if (x == 3.14) {
// CODE
} else {
// CODE
}
}
G.EXP.03 && 、 ||、? 和 ?? 操作符的右侧操作数不要修改程序状态
【级别】要求
逻辑与(&&
)、逻辑或(||
)、问号操作符(?
)和 coalescing(??
)表达式中的右操作数是否被求值,取决于左操作数的求值结果,当左操作数的求值结果可以得到整个表达式的结果时,不会再计算右操作数的结果。如果右操作数可能修改程序状态,则不能确定该修改是否发生,因此,规定逻辑与、逻辑或、问号操作符和 coalescing 操作符的右操作数中不要修改程序状态。
这里修改程序状态主要指修改变量及其成员(如修改全局变量、取放锁)、进行 IO 操作(如读写文件,收发网络包)等。
【反例】
var count: Int64 = 0
func add(x: Int64): Int64 {
count += x // 修改了全局变量
return count
}
main(): Int64 {
let isOk = false
let num = 5
if (isOk && (add(num) != 0)) { // 不符合: && 的右操作数中修改了程序状态
return 0
} else {
return 1
}
}
【正例】:
var count: Int64 = 0
func add(x: Int64): Int64 {
count += x // 修改了全局变量
return count
}
main(): Int64 {
let isOk = false
let num = 5
if (isOk) { // 使用显式的条件判断来区分操作是否被执行
if (add(num) != 0) {
return 0
} else {
return 1
}
} else {
return 1
}
}
G.EXP.04 尽量避免副作用发生依赖于操作符的求值顺序
【级别】建议
【描述】
表达式的求值顺序会影响副作用发生的顺序,应尽量避免副作用发生依赖于操作符的求值顺序。
通常操作符或表达式的求值顺序是先左后右,但以下情形求值顺序会比较特殊,尤其要避免副作用发生依赖于表达式的求值顺序:
- 对于赋值表达式,总是先计算右边的表达式,再计算 = 左边的表达式,最后进行赋值;
- 复合赋值表达式
a op= b
不能简单看做赋值表达式与其它二元操作符的组合a = a op b
,a op= b
中的a
只会被求值一次,而a = a op b
中的a
会被求值两次; try {e1} catch (catchPattern) {e2} finally {e3}
表达式的求值顺序,依赖e1
,e2
,e3
是否抛出异常;- 函数调用时,参数求值顺序是按照定义时顺序从左到右,而非按照调用时的实参顺序;
- 如果函数调用的某个参数是 Nothing 类型,则该参数后面的参数不会被求值,函数调用本身也不会被执行;
【反例】
如下代码示例中,main 中的复合赋值表达式的左操作数调用了一个有副作用的函数:
class C {
var count = 0
var num = 0
}
var c = C()
func getC(): C {
c.count += 1 // 副作用
return c
}
main(): Int64 {
let num = 5
getC().count += num // 不符合:副作用依赖了表达式的求值顺序和求值次数。
return 1
}
G.EXP.05 用括号明确表达式的操作顺序,避免过分依赖默认优先级
【级别】建议
【描述】
当表达式包含不常用、优先级易混淆的操作符时,建议使用括号明确表达式的操作顺序,防止因默认的优先级与实现意图不符而导致程序出错。
然而过多的括号也会分散代码降低其可读性,下面是对如何使用括号的建议:
-
一元操作符,不需要使用括号
func test(a: Int64, b: Bool, c: Bool) { let foo = -a // 一元操作符,不需要括号 if (b || !c) {} // 一元操作符,不需要括号 }
-
涉及位操作,推荐使用括号
-
如果不涉及多种操作符,不需要括号
涉及多种操作符混合使用并且优先级容易混淆的场景,建议使用括号明确表达式操作顺序。
func test() { let (a, b, c) = (1, 2, 3) let (p, q, r) = (true, false, true) let foo = a + b + c // 操作符相同,不需要括号 if (p && q && r) {} // 操作符相同,不需要括号 let bar = 1 << (2 + 3) // 操作符不同,优先级易混淆,需要括号 }
【正例】:
main(): Int64 {
var a = 0
var b = 0
var c = 0
a = 1 << (2 + 3)
a = (1 << 2) + 3
c = (a & 0xFF) + b
if ((a & b) == 0) {
return 0
} else {
return 1
}
}
【反例】
main(): Int64 {
var a = 0
var b = 0
var c = 0
a = 1 << 2 + 3 // 涉及位操作符,需要括号
c = a & 0xFF + b // 涉及位操作符,需要括号
if (a & b == 0) { // 涉及位操作符,需要括号
return 0
} else {
return 1
}
}
对于常用、不易混淆优先级的表达式,不需要强制增加额外的括号。例如:
main(): Int64 {
var a = 0
var b = 0
var c = 0
var d = a + b + c // 操作符相同,可以不加括号
var e = (a + b, c) // 逗号两边的表达式,不需要括号
if (a > b && c > d) { // 逻辑表达式,根据子表达式的复杂情况选择是否加括号
return 0
} else {
return 1
}
}
G.EXP.06 Bool 类型比较应避免多余的 == 或 !=
【级别】建议
【描述】
在 if 表达式、while、do while 表达式等使用到 Bool 类型表达式的位置,对于 Bool 类型的判断,应该避免多余的 == 或 !=。
【正例】:
func isZero(x: Int64):Bool {
return x == 0
}
main(): Int64 {
var a = true
var b = isZero(1)
if (a && !b) {
return 1
} else {
return 0
}
}
【反例】
func isZero(x: Int64):Bool {
return (x == 0) == true
}
main(): Int64 {
var a = true
var b = isZero(1)
if (a == true && b != true) {
return 1
} else {
return 0
}
}
G.EXP.07 比较两个表达式时,左侧倾向于变化,右侧倾向于不变
【级别】建议
【描述】
当可变变量与常量比较时,如果常量放左侧,如 if (MAX == v)
不符合阅读习惯,而 if (MAX > v)
更是难于理解。
应当按人的正常阅读、表达习惯,将常量放右侧。
【反例】
import std.collection.ArrayList
const MAX_LEN = 99999
func maxIndex(arr: ArrayList<Int>) {
let len = arr.size
// 不符合,常量在左,let 修饰的变量在右
if (MAX_LEN < len) {
throw Exception("too long")
} else {
var i = 0
var maxI = 0
// 不符合,let 修饰的变量在左,var 修饰的变量在右
while (len > i) {
if (arr[i] > arr[maxI]) {
maxI = i
}
i++
}
return maxI
}
}
【正例】:
import std.collection.ArrayList
const MAX_LEN = 99999
func maxIndex(arr: ArrayList<Int>) {
let len = arr.size
if (len > MAX_LEN) {
throw Exception("too long")
} else {
var i = 0
var maxI = 0
while (i < len) {
if (arr[i] > arr[maxI]) {
maxI = i
}
i++
}
return maxI
}
}
也有例外情况,如使用 if (MIN < a && a < MAX)
用来描述区间时,前半段表达式中不可变变量在左侧也是允许的。
异常与错误处理
G.ERR.01 恰当地使用异常或错误处理机制
【级别】建议
【描述】
仓颉提供了 Exception 用来处理异常或错误:Exception 给函数提供了一条异常返回路径,用于表示函数无法正常执行完成,或无法正常返回结果的情形。
对于异常或错误处理机制的建议如下:
- Exception 应避免不加任何处理就丢掉错误信息;
- 对于会抛 Exception 的 API,须在注释中说明可能出现的 Exception 类型;
例外:
- 系统调用、FFI 可以使用返回错误码的形式,与原 API 保持一致。
G.ERR.02 防止通过异常抛出的内容泄露敏感信息
【级别】要求
【描述】
如果在传递异常的时候未对其中的敏感信息进行过滤,常常会导致信息泄露,而这可能帮助攻击者尝试发起进一步的攻击。攻击者可以通过构造恶意的输入参数来发掘应用的内部结构和机制。不管是异常中的文本消息,还是异常本身的类型都可能泄露敏感信息。因此,当异常被传递到信任边界以外时,必须同时对敏感的异常消息和敏感的异常类型进行过滤。
【反例】
func exceptionExample(path: String): Unit {
var file: File
if (!File.exists(path)) {
// 异常消息和类型泄露敏感信息
throw IOException("File does not exist")
}
file = File(path, Append)
// CODE
}
当打开的源文件不存在时,程序会抛出 IOException 异常,并提示 “File does not exist”。这使得攻击者可以不断传入伪造的路径名称来重现出底层文件系统结构。
【反例】
func exceptionExample(path: String): Unit {
var file: File
if (!File.exists(path)) {
// 异常净化
throw IOException()
}
file = File(path, Append)
// CODE
}
此例中虽然报错信息并未透露错误原因,但是对于不同的错误原因仍会抛出不同类型的异常。攻击者可以根据程序的行为推断出有关文件系统的敏感信息。未对用户输入做限制,使得系统面临暴力攻击的风险,攻击者可以多次传入所有可能的文件名进行查询来发现有效文件。如果传入一个文件名后程序返回一个 IOException 异常,则表明该文件不存在,否则说明该文件是存在的。
【正例】:
func exceptionExample(path: String): Unit {
var file: File
if (!File.exists(path)) {
// 安全策略
println("Invalide file")
return
}
file = File(path, Append)
// CODE
}
【正例】:
func exceptionExample(index: Int32): Unit {
var path: String
var file: File
// 限制输入
match (index) {
case 1 => path = "/home/test1"
case 2 => path = "/home/test2"
case _ => return
}
file = File(path, Append)
// CODE
}
这个正确示例限制用户只能打开 /home/test1 与 /home/test2。同时,它也会过滤在 catch 块中捕获的异常中的敏感信息。
例外场景:
对出于问题定位目的,可将敏感异常信息记录到日志中,但必须做好日志的访问控制,防止日志被任意访问,导致敏感信息泄露给非授权用户。
G.ERR.03 避免对 Option 类型使用 getOrThrow 函数
【级别】建议
【描述】
仓颉使用 Option 类型来避免空指针问题,若对 Option 类型使用 getOrThrow 来获取其内容,容易导致忽略异常的处理,造成等同于空指针的效果。因此应尽量避免对 Option 类型使用 getOrThrow 函数。
【反例】
func getOne(dict: HashMap<String, Int64>, name: String): Int64 {
return dict.get(name).getOrThrow()
}
该错误示例没有考虑传入的名字可能不存在的情况,只使用了 getOrThrow 而没有处理异常。这是一种危险的编码风格,并不推荐。
【正例】
const DEFAULT_VALUE = 0
func getOne(dict: HashMap<String, Int64>, name: String): Int64 {
return dict.get(name) ?? DEFAULT_VALUE
}
该正确示例中,在 Option 中值不存在的情况下提供了默认值,而不是使用 getOrThrow。
例外场景:
对于调用开源三方件,三方件中通过 getOrThrow 抛出 NoneValueException 异常时,可以捕获 NoneValueException,并对该异常进行处理。
包和模块化
G.PKG.01 避免在 import 声明中使用通配符 *
【级别】建议
【描述】
使用 import xxx.*
会导致如下问题:
-
代码可读性问题:很难从代码中清楚地看到当前包依赖其它包的哪些实体 (类型,变量或函数等),也很难直接看出来一些实体是从哪个包来的;
-
形成意外的重载。
【反例】:
// test1.cj
package test1
public open class Base {
...
}
public class Sub <: Base {
...
}
public func f(a: Base) {
...
}
//file test2.cj
package test2
import test1.*
class Basa {
var m = Sub()
}
func f(a: Basa) {
...
}
main() {
f(Base()) // Miswriting Basa as Base, but no compiler error.
}
【正例】
// test1.cj
package test1
public open class Base {
...
}
public class Sub <: Base {
...
}
public func f(a: Base) {
...
}
//file test2.cj
package test2
import test1.Sub
class Basa {
var m = Sub()
}
func f(a: Basa) {
...
}
main() {
f(Base()) // Error,误将 Basa 写成了 Base,会编译报错
}
线程同步
G.CON.01 禁止将系统内部使用的锁对象暴露给不可信代码
【级别】要求
【描述】
在仓颉中可以通过 synchronized 关键字和一个 ReentrantMutex 对象对所修饰的代码块进行保护,使得同一时间只允许一个线程执行里面的代码。攻击者可以通过获取该 ReentrantMutex 对象来触发条件竞争与死锁,进而引起拒绝服务(DoS)。
防御这个漏洞一种方法就是使用私有锁对象。
【反例】
import std.sync.*
import std.time.*
class SomeObject {
public let mtx: ReentrantMutex = ReentrantMutex()
...
public func put(x: Object) {
synchronized(mtx) {
...
}
}
}
//Trusted code
var so = SomeObject()
...
//Untrusted code
func untrusted() {
synchronized(so.mtx) {
while (true) {
sleep(100 * Duration.nanosecond)
}
}
}
使用 public 修饰锁对象,攻击者可以直接无限持有 mtx 锁,使得其它调用 put 函数的线程被阻塞。
【正例】
import std.sync.*
class SomeObject {
private let mtx: ReentrantMutex = ReentrantMutex()
// CODE
public func put(x: Object) {
synchronized(mtx) {
// CODE
}
}
}
将锁对象设置为 private 类型,攻击者无法无限持有锁。
例外场景:
包私有的类可以不受该规则的约束,因为他们无法被包外的非受信代码直接访问。
对于非受信代码无法获取执行同步操作的对象的场景下,可以不受该规则的约束。
P.01 使用相同的顺序请求锁,避免死锁
【级别】要求
【描述】
为避免多线程同时操作共享变量导致冲突,必须对共享变量进行保护,防止被并行地修改和访问。进行同步操作可以使用 ReentrantMutex 对象。当两个或多个线程以不同的顺序请求锁时,就可能会发生死锁。仓颉自身不能防止死锁也不能对死锁进行检测。所以程序必须以相同的顺序来请求锁,避免产生死锁。
【反例】
import std.sync.*
class BankAccount {
private var balanceAmount: Float64 = 0.0
public let mtx: ReentrantMutex = ReentrantMutex()
// CODE
public func depositAmount(ba: BankAccount, amount: Float64) {
synchronized(mtx) {
synchronized(ba.mtx) {
if (balanceAmount> amount) {
ba.balanceAmount += amount
balanceAmount -= amount
}
}
}
}
}
上面的错误示例会存在死锁的情况。当 bankA / bankB 两个银行账户在不同线程同步互相转账时,就可能导致死锁的问题。
【正例】
import std.sync.*
class BankAccount {
private var balanceAmount: Float64 = 0.0
public let mtx: ReentrantMutex = ReentrantMutex()
private var id: Int32 = 0 // Unique for each BankAccount
// CODE
public func depositAmount(ba: BankAccount, amount: Float64) {
var former: ReentrantMutex
var latter: ReentrantMutex
if (id > ba.id) {
former = ba.mtx
latter = mtx
} else {
former = mtx
latter = ba.mtx
}
synchronized(former) {
synchronized(latter) {
if (balanceAmount > amount) {
ba.balanceAmount += amount
balanceAmount -= amount
}
}
}
}
}
上述正确示例使用了一个全局唯一的 id 来保证不同线程使用相同的顺序来申请和释放锁对象,因此不会导致死锁问题。
G.CON.02 在异常可能出现的情况下,保证释放已持有的锁
【级别】要求
【描述】
一个线程中没有正确释放持有的锁会使其他线程无法获取该锁对象,导致阻塞。在发生异常时,要确保程序正确释放当前持有的锁。注:在发生异常时,通过 synchronized 进行同步的代码块的锁会被自动释放,但是通过 mtx.lock() 获得的锁不会被自动释放,需要开发者手动释放。
【反例】
import std.sync.*
class Foo {
private let mtx: ReentrantMutex = ReentrantMutex()
public func doSomething(a: Int64, b: Int64) {
var c: Int64
try {
mtx.lock()
// CODE
c = a / b
mtx.unlock()
} catch (e: ArithmeticException) {
// Handle exception
// CODE
} finally {
// CODE
}
}
}
上述错误示例中,使用 ReentrantMutex 锁,发生算数运算错误时,catch 及 finally 代码块中没有释放锁操作,导致锁没有释放。
【正例】
import std.sync.*
class Foo {
private let mtx: ReentrantMutex = ReentrantMutex()
public func doSomething(a: Int64, b: Int64) {
var c: Int64
try {
mtx.lock()
// CODE
c = a / b
} catch (e: ArithmeticException) {
// Handle exception
// CODE
} finally {
mtx.unlock()
// CODE
}
}
}
上述正确示例中,成功执行锁定操作后,将可能抛出异常的操作封装在 try 代码块中。锁在执行可能发生异常的代码块前获取,可保证在执行 finally 代码时正确持有锁。在 finally 代码块中调用 mtx.unlock(),可以保证不管是否发生异常都可以释放锁。
G.CON.03 禁止使用非线程安全的函数来覆写线程安全的函数
【级别】要求
【描述】
使用非线程安全的函数覆写基类的线程安全函数,可能会导致不恰当的同步。比如,子类将基类的线程安全的函数覆写为非安全函数,这样就违背了覆写同步函数的要求。这样很容易导致难以定位的问题的产生。
被设计为可继承的类,这些类对应的锁策略必须要详细记录说明。方便子类继承时,沿用正确的锁策略。
【反例】
import std.sync.*
open class Base {
private let baseMtx: ReentrantMutex = ReentrantMutex()
public open func doSomething() {
synchronized(baseMtx) {
// CODE
}
}
}
class Derived <: Base {
public override func doSomething() {
// CODE
}
}
上述错误示例中,子类 Derived 覆写了基类 Base 的同步函数 doSomething() 为非线程同步函数。Base 类的 doSomething() 函数可被多线程正确使用,但 Derived 类不可以。因为接受 Base 实例的线程同时也可以接受其子类,所以可能会导致难以诊断的程序错误。
【正例】
import std.sync.*
open class Base {
private let baseMtx: ReentrantMutex = ReentrantMutex()
public open func doSomething() {
synchronized(baseMtx) {
// CODE
}
}
}
class Derived <: Base {
private let mtx: ReentrantMutex = ReentrantMutex()
public override func doSomething() {
synchronized(mtx) {
// CODE
}
}
}
上述正确示例中,通过使用一个私有的锁对象来同步的函数覆写 Base 类中的同步函数 doSomething(),确保了 Derived 类是线程安全的。
另外,上面示例中,子类与基类的 doSomething() 函数使用的是不同的锁,实际编码过程中,要考虑是否会产生影响。在设计过程中,要尽量避免类似的继承导致的同步问题。
P.02 避免数据竞争(data race)
【级别】要求
【描述】
仓颉语言中,内存模型中的每个操作都采用 happens-before 关系,来规定并发执行中,读写操作允许读到什么值,不允许读到什么值。两个线程分别对同一个变量进行访问操作,其中至少一个操作是写操作,且这两个操作之间没有 happens-before 关系,就会产生 data race。正确同步的(correctly synchronized)执行是指没有 data race 的执行。仓颉语言内存模型中规定,如果存在 data race,那么行为是未定义的,因此要求必须避免 data race。在仓颉中通常采用锁机制完成对共享资源的同步,并且同一个共享资源应该使用同一个锁来进行保护。注:happens-before 关系的正式定义见仓颉语言规范定义。
对 “同一个数据” 的定义:
- 对同一个 primitive type、enum、array 类型的变量或者 struct/class 类型的同一个 field 的访问,都算作同一个数据。
- 对 struct/class 类型的不同 field 的访问,算作不同数据 。
【反例】
import std.sync.*
import std.time.*
var res: Int64 = 0
main(): Int64 {
var i: Int64 = 0
while (i < 100) {
i++
spawn {
res = res + 1
}
}
sleep(Duration.second)
print(res.toString())
return 0
}
上述错误示例中,多个线程同时对全局变量 res 进行了读写操作,导致 data race, 最终 res 的值为一个非预期值。
【正例】
import std.sync.*
import std.time.*
var res: Int64 = 0
main(): Int64 {
var i: Int64 = 0
let mtx = ReentrantMutex()
while (i < 100) {
i++
spawn {
synchronized (mtx) {
res = res + 1
}
}
}
sleep(Duration.second)
print(res.toString())
return 0
}
上述正确示例中,通过使用 synchronized 来保护对全局变量 res 的修改。
一般来说,如果使用锁,那么读和写都要加锁,而不是写线程需要加锁,而读的线程可以不加锁。
【反例】
import std.sync.*
import std.time.*
var a: Int64 = 0
var b: Int64 = 0
main(): Int64 {
let mtx = ReentrantMutex()
spawn {
mtx.lock()
a = 1
b = 1
mtx.unlock()
}
spawn {
while (true) {
if (a == 0) {
continue
}
if (b != 1) {
print("Fail\n")
} else {
print("Success\n")
}
break
}
}
sleep(Duration.second)
return 0
}
上述错误示例中,对于 a、b 的写入是在锁的保护下进行的,但是没有在锁的保护中进行读取,可能导致读取到的值不符合预期。
【正例】
import std.sync.*
import std.time.*
var a: Int64 = 0
var b: Int64 = 0
main(): Int64 {
let mtx = ReentrantMutex()
spawn {
mtx.lock()
a = 1
b = 1
mtx.unlock()
}
spawn {
while (true) {
mtx.lock()
if (a == 0) {
mtx.unlock()
continue
}
if (b != 1) {
print("Fail\n")
} else {
print("Success\n")
}
mtx.unlock()
break
}
}
sleep(Duration.second)
return 0
}
上述正确示例中,对于 a、b 的写入和读取均是在锁的保护下进行的,结果符合预期。
G.CON.04 避免在产生阻塞操作中持有锁
【级别】建议
【描述】
在耗时严重或阻塞的操作中持有锁,可能会严重降低系统的性能。另外,无限期的阻塞相互关联的线程,会导致死锁。阻塞操作一般包括:网络、文件和控制台 I/O 等,将一个线程延时同样会形成阻塞操作。所以程序持有锁时应避免执行这些操作。
【反例】
import std.sync.*
import std.time.*
let mtx: MultiConditionMonitor = MultiConditionMonitor()
let c: ConditionID = mtx.newCondition()
func doSomething(time: Duration) {
synchronized(mtx) {
sleep(time)
}
}
上述错误示例中,doSomething() 函数是同步的,当线程挂起时,其他线程也不能使用该同步函数。
【正例】
import std.sync.*
import std.time.*
let mtx: MultiConditionMonitor = MultiConditionMonitor()
let c: ConditionID = synchronized(mtx) {
mtx.newCondition()
}
func doSomething(timeout: Duration) {
synchronized(mtx) {
while (/* waiting for something */) {
mtx.wait(c, timeout: timeout) // Immediately releases the current mutex
}
}
}
上述正确示例中,使用 mtx 对象的 wait 函数设置一个 timeout 期限并阻塞当前线程,然后将 mtx 对象锁释放。在 timeout 期限到达或者该线程被 mtx 对象的 notify() 或 notifyAll() 函数唤起时,该线程会重新尝试获取 mtx 锁。
【反例】
import std.sync.*
// Class Page is defined separately.
// It stores and returns the Page name via getName()
let pageBuff: Array<Page> = Array<Page>(MAX_PAGE_SIZE) { i => Page() }
let mtx = ReentrantMutex()
public func sendPage(socket: Socket, pageName: String): Bool {
synchronized(mtx) {
var write_bytes: Option<Int64>
var targetPage = None<Page>
// Send the Page to the server
for (p in pageBuff) {
match (p.getName().compareTo(pageName)) {
case EQUAL => targetPage = Some<Page>(p)
case _ => ...
}
}
// Requested Page does not exist
match (targetPage) {
case None => return false
case _ => ...
}
// Send the Page to the client
// (does not require any synchronization)
write_bytes = socket.write(targetPage.getOrThrow().getBuff())
...
}
return true
}
上述错误示例中,sendPage() 函数会从服务器发送一个 page 对象的数据到客户端。当多个线程并发访问时,同步函数会保护 pageBuf 队列,而 writeObject() 操作会导致延时,在高延时的网络或当网络条件本身存在丢包时,该锁会被长期无意义地持有。
【正例】
import std.sync.*
// Class Page is defined separately.
// It stores and returns the Page name via getName()
// let pageBuff: Array<Page> = Array<Page>(MAX_PAGE_SIZE) { i => Page() }
let pageBuff = ArrayList<Page>()
let mtx = ReentrantMutex()
public func sendPage(socket: Socket, pageName: String): Bool {
let targetPage = getPage(pageName)
match (targetPage) {
case None => return false
case _ => ...
}
// Send the Page to the client
// (does not require any synchronization)
deliverPage(socket, targetPage.getOrThrow())
...
return true
}
// Requires synchronization
private func getPage(pageName: String): Option<Page> {
synchronized(mtx) {
var targetPage = None<Page>
for (p in pageBuff) {
match (p.getName().compareTo(pageName)) {
case EQUAL => targetPage = Some<Page>(p)
case _ => ...
}
}
return targetPage
}
}
private func deliverPage(socket: Socket, targetPage: Page) {
var write_bytes: Option<Int64>
// Send the Page to the client
// (does not require any synchronization)
write_bytes = socket.write(targetPage.getBuff())
...
}
上述正确示例中,将原来的 sendPage() 分为三个具体步骤执行,不同步的 sendPage() 函数调用同步的 getPage() 函数来在 pageBuff 队列中获得请求的 page。在取得 page 后,会调用不同步的 deliverPage() 函数类提交 page 到客户端。
例外场景:
向调用者提供正确终止阻塞操作的类可不遵守该要求。
G.CON.05 避免使用不正确形式的双重锁定检查
【级别】建议
【描述】
双重锁定(double-checked locking idiom)是一种软件设计模式,通常用于延迟初始化单例。主要通过在进行获取锁之前先检查单例对象是否创建(第一次检查),在获取锁以后,再次检查对象是否创建(第二次检查),以此减少并发获取锁的开销。
但是取决于具体实现的内存模型,不正确的双重锁定检查使用可能会导致一个未初始化或部分初始化的对象对其它线程可见。因此只有在能为实例的完整构建建立正确的 happens-before 关系的情况下,才可以使用双重锁定检查。
【反例】
import std.sync.*
class Foo {
private var helper: Option<Helper> = None<Helper>
private let mtx: ReentrantMutex = ReentrantMutex()
public func getHelper(): Helper {
match (helper) {
case None =>
synchronized(mtx) {
match (helper) {
case None =>
let temp = Helper()
helper = Some<Helper>(temp)
return temp
case Some(h) => return h
}
}
case Some(h) => return h
}
}
}
上述错误示例中,使用了双重锁定检查的错误形式。对 Helper 对象进行初始化的写入和对 Helper 数据成员的写入,可能不按次序进行或完成。因此,一个调用 getHelper() 的线程可能会得到指向一个 helper 对象的非空引用,但该对象的数据成员为默认值而不是构造函数中设置的值。
【正例】
import std.sync.*
class Foo {
private var helper = AtomicOptionReference<Helper>()
private let mtx: ReentrantMutex = ReentrantMutex()
public func getHelper(): Helper {
match (helper.load()) {
case None =>
synchronized(mtx) {
match (helper.load()) {
case None =>
let temp = Helper()
helper = AtomicOptionReference<Helper>(temp)
return temp
case Some(h) => return h
}
}
case Some(h) => return h
}
}
}
上述例子将使用 AtomicReference 类对 Helper 的使用进行了封装,该类型会禁用编译优化,使得对 helper 对象的操作满足 happens-before 关系。
【正例】
class Foo {
private static let helper: Helper = Helper()
public static func getHelper(): Helper {
return helper
}
}
上述正确示例中,在对静态变量的声明中完成了 helper 字段的初始化。但是该实例没有使用延迟初始化。
数据校验
G.CHK.01 跨信任边界传递的不可信数据使用前必须进行校验
【级别】要求
【描述】
程序可能会接收来自用户、网络连接或其他来源的不可信数据, 并将这些数据跨信任边界传递到目标系统(如浏览器、数据库等)。来自程序外部的数据通常被认为是不可信的,不可信数据的范围包括但不限于:网络、用户输入(包括命令行、界面)、命令行、文件(包括程序的配置文件)、环境变量、进程间通信(包括管道、消息、共享内存、socket、RPC)、跨信任域函数参数(对于 API)等。在使用这些数据前需要进行合法性校验,否则可能会导致不正确的计算结果、运行时异常、不一致的对象状态,甚至引起各种注入攻击,对系统造成严重影响。 对于外部数据的具体校验,要结合实际的业务场景采用与之相对的校验方式来消除安全隐患;对于缺少校验规则的场景,可结合其他的措施进行防护,保证不会存在安全隐患。
由于目标系统可能无法区分处理畸形的不可信数据,未经校验的不可信数据可能会引起某种注入攻击,对系统造成严重影响,因此,必须对不可信数据进行校验,且数据校验必须在信任边界以内进行(如对于 Web 应用,需要在服务端做校验)。数据校验有输入校验和输出校验,对从信任边界外传入的数据进行校验的叫输入校验,对传出到信任边界外的数据进行校验的叫输出校验。
尽管仓颉已经提供了强大的编译时和运行时检查,能拦截空指针、缓冲区溢出、整数溢出等问题,但无法保证数据的合法性和准确性,无法拦截注入攻击等,开发者仍应该关注不可信数据。
对外部数据的校验包括但不局限于:
- 校验 API 接口参数合法性;
- 校验数据长度;
- 校验数据范围;
- 校验数据类型和格式;
- 校验集合大小;
- 校验外部数据只包含可接受的字符(白名单校验),尤其需要注意一些特殊情况下的特殊字符,例如附录 A 命令注入相关字符。
对于外部数据的校验,要注意以下两点:
- 如果需要,外部数据校验前要先进行标准化:例如
\uFE64
、<
都可以表示<
,在 web 应用中, 如果外部输入不做标准化,可以通过\uFE64
绕过对<
限制。 - 对外部数据的修改要在校验前完成,保证实际使用的数据与校验的数据一致。
如下描述了四种数据校验策略(任何时候,尽可能使用接收已知合法数据的 “白名单” 策略)。
接受已知好的数据
这种策略被称为 “白名单” 或者 “正向” 校验。该策略检查数据是否属于一个严格约束的、已知的、可接受的合法数据集合。例如,下面的示例代码确保 name 参数只包含字母、数字以及下划线。
import std.regex.*
func verify() {
...
match (Regex("^[0-9A-Za-z_]+$").matches(name)) {
case None => throw IllegalArgumentException()
case _ => ()
}
}
拒绝已知坏的数据
这种策略被称为 “黑名单” 或者 “负向” 校验,相对于正向校验,这是一种较弱的校验方式。由于潜在的不合法数据可能是一个不受约束的无限集合,这就意味着你必须一直维护一个已知不合法字符或者模式的列表。如果不定期研究新的攻击方式并对校验的表达式进行日常更新,该校验方式就会很快过时。
import std.regex.*
func removeJavascript(input: String): String {
var matchData = Regex("javascript").matcher(input).find()
match (matchData) {
case None => return input
case _ => ""
}
}
“白名单” 方式净化
对任何不属于已验证合法字符数据中的字符进行净化,然后再使用净化后的数据,净化的方式包括删除、编码、替换。比如,如果你期望接收一个电话号码,那么你可以删除掉输入中所有的非数字字符,“(555)123-1234”,“555.123.1234”,与 “555";DROP TABLE USER;--123.1234” 全部会被转换为 “5551231234”,然后再对转换的结果进行校验。又比如,对于用户评论栏的文本输入,由于几乎所有的字符都可能被用到,确定一个合法的数据集合是非常困难的,一种解决方案是对所有非字母数字进行编码,如对“I like your web page!” 使用 URL 编码,其净化后的输出为 “I+like+your+web+page%21”。“白名单” 方式净化不仅有利于安全,它也允许接收和使用更宽泛的有效用户输入。
“黑名单” 方式净化
为了确保输入数据是 “安全” 的,可以剔除或者转换某些字符(例如,删除引号、转换成 HTML 实体)。跟 “黑名单” 校验类似,随着时间推移不合法字符的范围很可能不一样,需要对不合法字符进行日常维护。因此,执行一个单纯针对正确输入的 “正向” 校验更加简单、高效、安全。
import std.regex.*
func quoteApostrophe(input: String): String {
var m = Regex("\\\\").matcher(input)
return m.replace("’");
}
G.CHK.02 禁止直接使用外部数据记录日志
【级别】要求
【描述】
直接将外部数据记录到日志中,可能存在以下风险:
- 日志注入:恶意用户可利用回车、换行等字符注入一条完整的日志;
- 敏感信息泄露:当用户输入敏感信息时,直接记录到日志中可能会导致敏感信息泄露;
- 垃圾日志或日志覆盖:当用户输入的是很长的字符串,直接记录到日志中可能会导致产生大量垃圾日志;当日志被循环覆盖时,这样还可能会导致有效日志被恶意覆盖。
所以外部数据应尽量避免直接记录到日志中,如果必须要记录到日志中,要进行必要的校验及过滤处理,对于较长字符串可以截断。对于记录到日志中的数据含有敏感信息时,将这些敏感信息替换为固定长度的 *,对于手机号、邮箱等敏感信息,可以进行匿名化处理。
【反例】
import std.log.*
func verifyLogin() {
...
if (loginSuccessful) {
simpleLogger.log(LogLevel.ERROR, "User login succeeded for:" + username)
} else {
simpleLogger.log(LogLevel.ERROR, "User login failed for:" + username)
}
}
此错误示例代码中,在接收到非法请求时,会记录用户的用户名,由于没有执行任何输入净化,这种情况下就可能会遭受日志注入攻击: 当 username 字段的值是 david 时,会生成一条标准的日志信息:
2021/06/01 2:19:10.123123 Error logger User login failed for: david
但是,如果记录日志时使用的 username 存在换行,如下所示:
2021/06/01 2:19:10.123123 Error logger User login failed for: david
INFO logger User login succeeded for: administrator
那么日志中包含了以下可能引起误导的信息:
2021/06/01 2:19:10.123123 Error logger User login failed for: david
2021/06/01 2:19:15.123123 INFO: logger User login succeeded for: administrator
【正例】
import std.regex.*
import std.log.*
func verifyLogin() {
...
match (Regex("[A-Za-z0-9_]+").matches(username)) {
case None => simpleLogger.log(LogLevel.ERROR, "User login failed for unauthorized user")
case _ where (loginSuccessful) =>
simpleLogger.log(LogLevel.ERROR, "User login succeeded for:" + username)
case _ =>
simpleLogger.log(LogLevel.ERROR, "User login failed for:" + username)
}
}
说明:外部数据记录到日志中前,进行有效字符的校验。
G.CHK.03 使用外部数据构造的文件路径前必须进行校验,校验前必须对文件路径进行规范化处理
【级别】要求
【描述】
文件路径来自外部数据时,必须对其合法性进行校验,否则可能会产生路径遍历漏洞,目录遍历漏洞使得攻击者能够转移到一个特定目录进行 I/O 操作。
在文件路径校验前要对文件路径进行规范化处理,使用规范化的文件路径进行校验。由于文件路径有多种表现形式,如绝对路径、相对路径,路径中可能会含各种链接、快捷方式、影子文件等,这些都会对文件路径的校验产生影响。路径中也可能会包含如下所示的文件名,使得验证变得困难:
- “.” 指目录本身;
- 在一个目录内,“..” 指该目录的上一级目录;
除此之外,还有与特定操作系统和特定文件系统相关的命名约定,也会使验证变得困难。
【反例】
func dumpSecureFile(path: String): Int32
{
if (isInSecureDir(Path(path))){
//dump the file
...
}
...
}
【正例】
func dumpSecureFile(path: String): Int32
{
let dir = Path(path)
let canPath = canonicalize(dir)
if (isInSecureDir(canPath)) {
//dump the file
...
}
...
}
这个正确示例使用了 DirectoryInfo.getCanonicalPath()函数,它能在所有的平台上对所有别名、快捷方式以及符号链接进行一致地解析。特殊的文件名,比如 “..” 会被移除,这样输入在验证之前会被简化成对应的标准形式。当使用标准形式的文件路径来做验证时,攻击者将无法使用../ 序列来跳出指定目录。
注意:如果在操作规范化后的路径发生错误(比如打开失败或者没有通过安全检查)时需要将路径打印到日志中,谨慎选择是否应该打印规范化后的路径,避免路径信息发生泄露。
G.CHK.04 禁止直接使用不可信数据构造正则表达式
【级别】要求
【描述】
正则表达式广泛用于匹配文本字符串。例如,POSIX 中 grep 实用程序支持用于查找指定文本中的模式的正则表达式。仓颉的 regex 包提供了 Regex 类,该类封装了一个编译过的正则表达式和一个 Matcher 类,通过 Matcher 类引擎,可以在字符串中进行匹配操作。
在仓颉中必须注意不能误用正则表达式的功能。攻击者可能会通过恶意构造的输入对初始化的正则表达式进行修改,比如导致正则表达式不符合程序规定要求。这种攻击称为正则注入 (regex injection), 可能会影响控制流,导致信息泄漏,或导致 ReDos 攻击。
以下是正则表达式可能被利用的方式:
匹配标志:不可信的输入可能覆盖匹配选项,然后有可能会被传给 Regex() 构造函数。
贪婪: 一个非受信的输入可能试图注入一个正则表达式,通过它来改变初始的那个正则表达式,从而匹配尽可能多的字符串,从而暴露敏感信息。
分组: 程序员会用括号包括一部分的正则表达式以完成一组动作中某些共同的部分。攻击者可能通过提供非受信的输入来改变这种分组。
非受信的输入应该在使用前净化,从而防止发生正则表达式注入。当用户必须指定正则表达式作为输入时,必须注意需要保证初始的正则表达式没有被无限制修改。在用户输入字符串提交给正则解析之前,进行白名单字符处理(比如字母和数字)是一个很好的输入净化策略。开发人员必须仅仅提供最有限的正则表达式功能给用户,从而减少被误用的可能。
ReDos 攻击是仓颉代码正则使用不当导致的常见安全风险。容易存在 ReDos 攻击的正则表达式主要有两类:
-
包含具有自我重复的重复性分组的正则,例如:
^(\d+)+$ ^(\d*)*$ ^(\d+)*$ ^(\d+|\s+)*$
-
包含替换的重复性分组,例如:
^(\d|\d\d)+$ ^(\d|\d?)+$
对于 ReDos 攻击的防护手段主要包括:
-
进行正则匹配前,先对匹配的文本的长度进行校验;
-
在编写正则时,尽量不要使用过于复杂的正则,尽量减少分组的使用,越复杂、分组越多越容易有缺陷,例如对于下面的正则:
^(([a-z])+\.)+[A-Z]([a-z])+$
存在 ReDos 风险,可以将多余的分组删除,这样在不改变检查规则的前提下消除了 ReDos 风险;
^([a-z]+\.)+[A-Z][a-z]+$
【反例】
let REGEX_PATTER: Regex = Regex("a(b|c+)+d") func test(arg: String) { match (REGEX_PATTER.matches(arg)) { case None => ... case _ => ... } }
【正例】
let REGEX_PATTER: Regex = Regex("a[bc]+d") func test(arg: String) { match (REGEX_PATTER.matches(arg)) { case None => ... case _ => ... } }
-
避免动态构建正则,当使用不可信数据构造正则时,要使用白名单进行严格校验。
【反例】
class LogSearch { func findLogEntry(search: String, log: String) { // Construct regex dynamically from user string var regex: String = "(.*? +public\\[\\d+\\] +.*" + search + ".*)" var logMatcher: Matcher = Regex(regex).matcher(log) ... } }
【正例】
class LogSearch { func findLogEntry(search: String, log: String) { // Sanitize search string let ss = StringBuilder() for (i in search.runes()) { if (i.isLetter() || i.isNumber() || i == '_' || i =='\'') { ss.append(i) } } let sanitized = ss.toString() // Construct regex dynamically from user string var regex: String = "(.*? +public\\[\\d+\\] +.*" + sanitized + ".*)" var logMatcher: Matcher = Regex(regex).matcher(log) ... } }
I/O 操作
G.FIO.01 临时文件使用完毕必须及时删除
【级别】要求
【描述】
程序运行时经常会需要创建临时文件。如果文件未被安全地创建或者用完后还是可访问的,具备本地文件系统访问权限的攻击者便可以利用临时文件进行恶意操作。删除已经不再需要的临时文件有助于对文件名和其他资源(如二级存储)进行回收利用。每一个程序在正常运行过程中都有责任确保已使用完毕的临时文件被删除。
【反例】
import std.fs.File
import std.fs.OpenOption
main(){
let pathName = "/mytemp/doc.txt";
let fs: File = File(pathName, CreateOrAppend)
...
fs.flush()
fs.close()
...
return 0
}
这个错误示例代码在运行结束时未将临时文件删除。
【正例】
import std.fs.File
import std.fs.OpenOption
main() {
let pathName = "/mytemp/doc.txt"
let fs: File = File(pathName, CreateOrAppend)
...
fs.flush()
fs.close()
File.delete(pathName)
...
return 0
}
这个正确示例代码在临时文件使用完毕之后、系统终止之前,显式地对其进行删除。
序列化和反序列化
G.SER.01 禁止序列化未加密的敏感数据
【级别】要求
【描述】
虽然序列化可以将对象的状态保存为一个字节序列,之后通过反序列化将字节序列又能重新构造出原来的对象,但是它并没有提供一种机制来保证序列化数据的安全性。因此,敏感数据序列化之后是潜在对外暴露的,可访问序列化数据的攻击者可以借此获取敏感信息并确定对象的实现细节。永远不应该被序列化的敏感信息包括:密钥、数字证书以及那些在序列化时引用敏感数据的类,防止敏感数据被无意识的序列化导致敏感信息泄露。另外,声明了可序列化标识对象的所有字段在序列化时都会被输出为字节序列,能够解析这些字节序列的代码可以获取到这些数据的值,而不依赖于该字段在类中的可访问性。因此,若其中某些字段包含敏感信息,则会造成敏感信息泄露。
【反例】
class People <: Serializable<People> {
var name: String
// 口令是敏感数据
var password: String
init(s: DataModelStruct) {
name = String.deserialize(s.get("name"))
password = String.deserialize(s.get("password"))
}
public func serialize(): DataModel {
DataModelStruct().add(field<String>("name", name))
DataModelStruct().add(field<String>("password", password))
}
public static func deserialize(s: DataModel): People {
let d = (s as DataModelStruct).getOrThrow()
People(d)
}
}
该错误示例允许将敏感成员变量 password 进行序列化和反序列化,可能会导致 password 信息泄露。
【正例】
class People <: Serializable {
var name: String
// 口令是敏感数据
var password: String
init(s: DataModelStruct) {
name = String.deserialize(s.get("name"))
password = ""
}
public func serialize(): DataModel {
DataModelStruct().add(field<String>("name", name))
}
public static func deserialize(s: DataModel): People {
let d = (s as DataModelStruct).getOrThrow()
People(d)
}
}
该正确示例在进行序列化和反序列化时跳过了 password 变量,避免了 password 信息被泄露。
G.SER.02 防止反序列化被利用来绕过构造函数中的安全操作
【级别】要求
【描述】
仓颉语言默认由用户提供序列化和反序列化函数,用户实现的反序列化函数中需要对各个字段进行校验。反序列化操作可以在绕过公开构造函数的情况下创建对象的实例,所以反序列化操作中的行为应该设计为与公开构造函数保持一致,这些行为包括: 对参数的校验、对属性赋初始值等; 否则,攻击者就可能会通过反序列化操作构造出与预期不符合的对象实例。仓颉语言使用反序列化功能时应关注此问题,需要在序列化和反序列化前后进行安全检查。
【反例】
class MySerializeDemo <: Serializable<MySerializeDemo> {
var value: Int64
init(v: Int64) {
value = if (v >= 0) { v } else { 0 }
}
private init(s: DataModelStruct) {
value = Int64.deserialize(s.get("value"))
}
public func serialize(): DataModel {
return DataModelStruct().add(field<Int64>("value", value))
}
public static func deserialize(s: DataModel): MySerializeDemo {
let d = (s as DataModelStruct).getOrThrow()
MySerializeDemo(d)
}
}
上述示例中,构造函数会对参数进行检查,保证 value 的值为非负值,但通过反序列化操作可构造 value 值为负值的对象示例。
【正例】
class MySerializeDemo <: Serializable<MySerializeDemo> {
var value: Int64
init(v: Int64) {
value = if (v >= 0) { v } else { 0 }
}
private init(s: DataModelStruct) {
let v = Int64.deserialize(s.get("value"))
value = if (v >= 0) { v } else { 0 }
}
public func serialize(): DataModel {
return DataModelStruct().add(field<Int64>("value", value))
}
public static func deserialize(s: DataModel): MySerializeDemo {
let d = (s as DataModelStruct).getOrThrow()
MySerializeDemo(d)
}
}
上述示例中, 反序列化操作中与构造函数中对 value 赋值操作保持一致,先检查后赋值。
G.SER.03 保证序列化和反序列化的变量类型一致
【级别】要求
【描述】
仓颉不会对序列化和反序列化使用的数据的数据类型进行检查,如果反序列化时使用的数据的数据类型和序列化时传入数据的数据类型不一致,则可能会造成数据错误。开发者需要保证序列化和反序列化时传入数据和接收数据的变量的变量类型一致。
【反例】
class MySerializeDemo <: Serializable<MySerializeDemo> {
var value: Int64
var msg: String
init(v: Int64) {
value = v
msg = match (value) {
case 0x0 => "zero"
case 0x7fffffff => "BIG INT"
case _ => "DEFAULT"
}
}
public func serialize() : DataModel {
DataModelStruct().add(field<Int64>("value", value))
}
private init(s: DataModelStruct) {
let v = Int32.deserialize(s.get("value"))
value = Int64(v)
msg = match (v) {
case 0x0 => "zero"
case 0x7fffffff => "BIG INT"
case _ => "DEFAULT"
}
}
public static func deserialize(s: DataModel): MySerializeDemo {
let d = (s as DataModelStruct).getOrThrow()
MySerializeDemo(d)
}
}
错误示例中序列化时传入的参数 value 是 Int64 类型,但是在接收的时候使用的是 Int32 类型的变量,因此会造成数据截断,导致反序列化的对象数据预期不一致。
【正例】
class MySerializeDemo <: Serializable<MySerializeDemo> {
var value: Int64
var msg: String
init(v: Int64) {
value = v
msg = match (value) {
case 0x0 => "zero"
case 0x7fffffff => "BIG INT"
case _ => "DEFAULT"
}
}
public func serialize(): DataModel {
DataModelStruct().add(field<Int64>("value", value))
}
private init(s: DataModelStruct) {
let v = Int64.deserialize(s.get("value"))
value = v
msg = match (v) {
case 0x0 => "zero"
case 0x7fffffff => "BIG INT"
case _ => "DEFAULT"
}
}
public static func deserialize(s: DataModel): MySerializeDemo {
let d = (s as DataModelStruct).getOrThrow()
MySerializeDemo(d)
}
}
正确示例中序列化和反序列化使用的变量的类型一致,保证了反序列化后得到的对象数据符合预期。
平台安全
G.SEC.01 进行安全检查的函数禁止声明为 open
【级别】建议
【描述】
实现安全检查功能的函数,如果可以被子类 override,恶意子类可以 override 安全检查函数,忽略这些安全检查,使安全检查失效。所以安全检查相关的函数禁止声明为 open
,防止被 override。
【反例】
class SecurityCheck {
...
public open func requestPasswordAuthentication(protocol: String, prompt: String, scheme: String): Bool {
if (checkProtocol(protocol) && checkPrompt(prompt) && checkScheme(scheme)) {
...
}
}
}
上述示例中,requestPasswordAuthentication 被声明为了 open 类型,攻击者可以构造恶意子类将该函数覆写,忽略其中的安全检查。
【正例】
class SecurityCheck {
...
public func requestPasswordAuthentication(protocol: String, prompt: String, scheme: String): Bool {
if (checkProtocol(protocol) && checkPrompt(prompt) && checkScheme(scheme)) {
...
}
}
}
上述示例中,requestPasswordAuthentication 没有被声明为 open 类型,防止被子类覆写。
P.03 对外部对象进行安全检查时需要进行防御性拷贝
【级别】要求
【描述】
如果一个可信类被声明为 open
,并且该类中存在 open
的涉及安全检查的函数,则会存在一定的安全隐患。攻击者可以通过继承该类并 override 其中 open
函数,来达到绕过安全检查的目的。因此,在对不可信的对象进行安全检查时,需要对其进行防御性拷贝,并且拷贝必须是深拷贝,然后对拷贝的对象进行安全检查,这样就能保证不会调用到攻击者 override 的函数。
【反例】
open class SecretFile {
var path: String
var stream: File
public open func getPath() {
return path
}
public open func getStream() {
return stream
}
...
}
class Foo {
public func getFileAsStream(file: SecretFile): File {
try {
this.securityCheck(file.getPath())
return file.getStream()
} catch (ex: IOException) {
// 处理异常
...
}
}
...
}
上述示例中,由于 SecretFile 是 open
的,并且 getPath()
函数也是 open
的,因此攻击者可以继承该类并 override getPath()
函数来绕过安全检查。如下代码所示,getPath()
函数第一次调用时会返回正常的文件路径,而之后的每次调用都会返回敏感文件路径。这样攻击者拿到的其实是 /etc/passwd
对应的 File
。
class UntrustedFile <: SecretFile {
private var count: Int32 = 0
public init(path: String) {
super(path)
}
public override func getPath(): String {
return if (count == 0) {
count++
"/tmp/pub"
} else {
"/etc/passwd"
}
}
}
【正例】
public func getFileAsStream(file: SecretFile): File {
var copy = SecretFile(file.getPath())
try {
this.securityCheck(copy.getPath())
return copy.getStream()
} catch (ex: IOException) {
// 处理异常
}
}
上述示例中,通过 File 的构造函数创建了一个新的文件对象,这样可以保证在 copy 对象上调用的任何函数均来自标准类库。
其他
G.OTH.01 禁止在日志中保存口令、密钥和其他敏感数据
【级别】要求
【描述】
在日志中不能输出口令、密钥和其他敏感信息,口令包括明文口令和密文口令。对于敏感信息建议采取以下方法:
- 不在日志中打印敏感信息。
- 若因为特殊原因必须要打印日志,则用固定长度的星号(
*
)代替输出的敏感信息。
【反例】
func test() {
let fs: File = File("xxx.log", CreateOrAppend)
let logger = SimpleLogger("Login", LogLevel.INFO, fs)
...
logger.info("Login success ,user is ${userName} and password is ${encrypt(pass)}")
}
【正例】
func test() {
let fs: File = File("xxx.log", CreateOrAppend)
let logger = SimpleLogger("Login", LogLevel.INFO, fs)
...
logger.info("Login success ,user is ${userName} and password is ****")
}
G.OTH.02 禁止将敏感信息硬编码在程序中
【级别】要求
【描述】
如果将敏感信息(包括口令和加密密钥)硬编码在程序中,可能会将敏感信息暴露给攻击者。任何能够访问到二进制文件的人都可以反编译二进制文件并发现这些敏感信息。因此,不能将敏感信息硬编码在程序中。同时,硬编码敏感信息会增加代码管理和维护的难度。例如,在一个已经部署的程序中修改一个硬编码的口令需要发布一个补丁才能实现。
【反例】
class DataHandler {
let pwd: String = "Huawei@123"
...
}
【正例】
class DataHandler {
public func checkPwd() {
let pwd = Array<UInt8>()
let read_bytes: Int64
let fs: File = File("serverpwd.txt", Open(true, true))
read_bytes = fs.read(pwd)
...
for (i in 0..pwd.size) {
pwd[i] = 0
}
...
}
}
这个正确代码示例从一个安全目录下的外部文件获取密码信息,在其使用完后立即从内存中将其清除可以防止后续的信息泄露。
G.OTH.03 禁止代码中包含公网地址
【级别】要求
【描述】
代码或脚本中包含用户不可见,不可知的公网地址,可能会引起客户质疑。
对产品发布的软件(包含软件包 / 补丁包)中包含的公网地址(包括公网 IP 地址、公网 URL 地址 / 域名、 邮箱地址)要求如下:
- 禁止包含用户界面不可见、或产品资料未描述的未公开的公网地址。
- 已公开的公网地址禁止写在代码或者脚本中,可以存储在配置文件或数据库中。 对于开源 / 第三方软件自带的公网地址必须至少满足上述第 1 条公开性要求。
【例外】 对于标准协议中必须指定公网地址的场景可例外,如 soap 协议中函数的命名空间必须指定的一个组装的公网 URL、http 页面中包含 w3.org 网址、XML 解析器中的 Feature 名等。
G.OTH.04 不要使用 String 存储敏感数据,敏感数据使用结束后应立即清 0
【级别】建议
【描述】
仓颉中 String
是不可变对象(创建后无法更改)。如果使用 String
保存口令、秘钥等敏感信息时,这些敏感信息会一直在内存中直至被垃圾收集器回收,如果该进程的内存可 dump,这些敏感信息就可能被泄露。应使用可以主动立即将内容清除的数据结构存储敏感数据,如 Array<Byte>
等。敏感数据使用结束后立即将内容清除,可有效减少敏感数据在内存中的保留时间,降低敏感数据泄露的风险。
【反例】
func foo() {
let password: String = getPassword()
verifyPassword(password)
}
func verifyPassword(pwd: String): Bool {
...
}
上面的代码中,使用 String
保存密码信息,可能会导致敏感信息泄露。
【正例】
func foo() {
let password: Array<Rune> = getPassword()
verifyPassword(password)
for (i in 0..password.size) {
password[i] = '\0'
}
}
func verifyPassword(pwd: Array<Rune>): Bool {
...
}
上述正确示例中 password 被声明为了数组类型,并且在使用完毕后被清空,保证了后续 password 内容不会被泄露。
语言互操作
说明: 仓颉在实现了强大的安全机制的同时,也实现了强大的兼容性:仓颉语言通过在 IR 层级上实现多语言的互通,可以高效调用其他主流编程语言,进而实现对其他语言库的复用和生态兼容。但由于仓颉的提供的安全机制仅适用于仓颉语言本身,并不适用于与其他语言交互操作的场景,因此在语言交互边界上仍是不安全的,与其他语言交互操作的安全问题仍需重视。
C 语言互操作
说明: 在有些情况下,仓颉语言需要直接和操作系统交互。为了满足这种需求,仓颉提供了与 C 语言互操作的机制,例如函数调用、类型映射、内存管理等。该部分规范主要关注仓颉中已有的安全机制在 C 语言中不适用而导致安全问题被忽视的情况,同时只关注程序运行时的安全问题,编译器能静态检查的错误不会被加入到规范中。由于仓颉和 C 交互的代码都放在 unsafe 上下文中,因此 unsafe 内的代码需要关注此类规范。
FFI.C.1 声明 struct
类型时,成员变量的顺序和类型要求和 C 语言侧保持一致
【级别】要求
【描述】
当使用 struct
来声明 C 语言中的结构体类型时,要求保持变量的顺序和类型一致。若没有保持一致,可能导致数据映射地址不正确,同时也可能因为类型不一致而出现截断错误。
进行结构体参数映射时,需要按照类型映射表来保证类型相匹配,具体参见附录 C 基础类型映射关系表。
【反例】
如下仓颉和 C 结构体中定义的前两个成员变量顺序不一致,导致类型大小顺序颠倒,类型对应不正确,在仓颉中能够使用 Int64 正常容纳的数据,映射到 C 语言的 int32_t 型,可能会出现截断错误。
// CTest.c
#include<stdio.h>
#include<stdint.h>
typedef struct {
int32_t x; // int32_t 对应仓颉的 Int32
int64_t y;
int64_t z;
int64_t a;
}CStruct;
void distance(CStruct cs) {
printf("x=%d, y=%lld, z=%lld, a=%lld\n", cs.x, cs.y, cs.z, cs.a);
}
// CJTest.cj
foreign func distance(f: CStruct): Unit
@C
struct CStruct {
var y: Int64 // 此处使用 Int64 对应 int32_t,不合法
var x: Int32
var z: Int64
var a: Int64
init(yy: Int64, xx: Int32, zz: Int64, aa: Int64) {
y = yy
x = xx
z = zz
a = aa
}
}
按照如下给结构体赋值,第一个参数明显超出 Int32 最大范围,但没有超出 Int64 的范围,在仓颉中使用 Int64 可以正常使用,但映射到 C 语言中使用的是 int32_t 型接收,会出现截断错误。
main() {
var y = CStruct(214748364888, 2147483647, 4, 8)
print("yres:\n")
unsafe { distance(y) }
}
yres:
x=88, y=140615081787391, z=4, a=8
【正例】
按照正确的对应顺序定义仓颉侧的 struct,则可以在编译时检查出数字范围溢出。
//CJTest.cj
foreign func distance(f: CStruct): Unit
@C
struct CStruct {
var x: Int32
var y: Int64
var z: Int64
var a: Int64
init(xx: Int32, yy: Int64, zz: Int64, aa: Int64) {
x = xx
y = yy
z = zz
a = aa
}
}
main() {
var y = CStruct(214748364888, 2147483647, 4, 8) // compiler will report error
print("yres:\n")
unsafe { distance(y) }
}
FFI.C.2 foreign
声明的函数参数类型、参数数量和返回值类型要求和 C 语言侧对应的函数参数类型、参数数量和返回值类型保持一致
【级别】要求
【描述】
仓颉使用 foreign
声明 C 语言侧函数时应保持参数数量、参数类型、返回值类型严格一致。若参数数量不一致,仓颉这边传入的参数数量不够的话,可能导致 C 语言侧变量的值未被初始化而访问到随机值;若参数类型不一致,可能会导致参数传递过去后被截断;返回值类型不一致,可能会导致仓颉接收函数返回值时出现截断问题。
同样的,在使用 CFunc<T, T>
声明函数指针时,也需要保持参数类型和类型限定符一致,若不一致,则可能出现截断错误。
【反例】
函数指针接收时参数类型和类型限定符不一致可能导致截断。如下示例中,C 语言侧函数指针为 int16_t 型,仓颉为 Int32 型,传入的参数在 Int32 范围内,但超过了 int16_t 范围,会出现截断错误。
// CTest.c
#include<stdio.h>
#include<stdint.h>
typedef int16_t(*func_t)(int16_t, int16_t);
int16_t add(int16_t a, int16_t b) {
int16_t sum = a + b;
printf("%d + %d = %d\n", a, b, sum);
return sum;
}
// Pass func ptr 'add' to CangJie.
func_t getFuncPtr() {
printf("this is from getFuncPtr. addr: %d\n", &add);
return add;
}
// CJTest.cj
foreign func getFuncPtr(): CFunc<(Int32, Int32) -> Int32>
main() {
var add: CFunc<(Int32, Int32) -> Int32> = unsafe { getFuncPtr() }
var bb = unsafe { add(214748364, 2) }
}
可以看到参数出现截断错误。
this is from getFuncPtr. addr: 575928392
-13108 + 2 = -13106
【正例】
仓颉侧和 C 语言侧类型保持一致,避免截断问题。
// CJTest.cj
foreign func getFuncPtr(): CFunc<(Int16, Int16) -> Int16>
main() {
var add: CFunc<(Int16, Int16) -> Int16> = unsafe { getFuncPtr() }
var bb = unsafe { add(214, 2) }
}
同时保持传参在类型大小范围内,将会正常执行。
this is from getFuncPtr. addr: -578103224
214 + 2 = 216
【反例】
参数类型不一致可导致截断。如下示例中,两侧互通函数 add 声明的参数类型不一致,传入后会发生截断。
//CTest.c
#include<stdio.h>
#include<stdint.h>
int add(short x, int y) { // 参数包含 short 型
printf("x = %x, y = %x\n", x, y);
return x + y;
}
// CJTest.cj
foreign func printf(fmt: CString, ...): Int32
foreign func add(x: Int32, y: Int32): Int32 // 参数全为 Int32 型
main() {
var a: Int32 = 0x1234567
var b: Int32 = 0
var res: Int32 = unsafe { add(a, b) }
unsafe {
var cstr = LibC.mallocCString("res = %x \n")
printf(cstr, res)
LibC.free(cstr)
}
}
运行结果如下,可以看到参数 x 传入后被截断,导致计算结果也被截断,仅保留了十六进制的低四位。
x = 4567, y = 0
res = 4567
【正例】
如下示例将互通函数两侧的参数都声明为 Int32 类型,避免截断问题。
// CTest.c
#include<stdio.h>
#include<stdint.h>
int add(int x, int y) {
printf("x = %x, y = %x\n", x, y);
return x + y;
}
// CJTest.cj
foreign func add(x: Int32, y: Int32): Int32
foreign func printf(fmt: CString, ...): Int32
main() {
var a: Int32 = 0x1234567
var b: Int32 = 0
var res: Int32 = unsafe { add(a, b) }
unsafe {
var cstr = LibC.mallocCString("res = %x \n")
printf(cstr, res)
LibC.free(cstr)
}
}
【反例】
参数数量不一致可导致访问任意值。互通函数两侧声明的参数数量不一致,会导致部分 C 侧变量没有得到初始化,从而访问到随机值。
// CTest.c
#include<stdio.h>
#include<stdint.h>
int add(int x, int y) {
printf("x = %x, y = %x\n", x, y);
return x + y;
}
// CJTest.cj
foreign func add(x: Int32): Int32
foreign func printf(fmt: CString, ...): Int32
main() {
var a: Int32 = 123
var res: Int32 = unsafe { add(a) } // 此处仅传递一个参数,第二个参数没有被初始化
unsafe {
var cstr = LibC.mallocCString("res = %d \n")
printf(cstr, res)
LibC.free(cstr)
}
}
运行结果如下,可以看到 y 是一个未知值,导致结果也是一个随机值。
x = 123, y = 1439015064
res = 1439015187
【正例】
// CJTest.cj
foreign func add(x: Int32, y: Int32): Int32
foreign func printf(fmt: CString, ...): Int32
main() {
var a: Int32 = 0x1234567
var b: Int32 = 0
var res: Int32 = unsafe { add(a, b) } // 此处正常传递两个参数
unsafe {
var cstr = LibC.mallocCString("res = %x \n")
printf(cstr, res)
LibC.free(cstr)
}
}
【反例】
函数返回类型不一致可导致截断。
// CTest.c
#include<stdio.h>
#include<stdint.h>
int add(int x, int y) {
printf("x = %x, y = %x\n", x, y);
return x + y;
}
// CJTest.cj
foreign func printf(fmt: CString, ...): Int32
foreign func add(x: Int32, y: Int32): Int16 // 此处返回类型和 C 侧声明不一致,可能出现截断问题
main() {
var a: Int32 = 0x12345
var b: Int32 = 0
var res: Int16 = unsafe { add(a, b) }
unsafe {
var cstr = LibC.mallocCString("res = %x \n")
printf(cstr, res)
LibC.free(cstr)
}
}
运行结果如下,可以看到计算结果仅保留十六进制的低四位,发生了截断。
x = 12345, y = 0
res = 2345
【正例】
// CJTest.cj
foreign func printf(fmt: CString, ...): Int32
foreign func add(x: Int32, y: Int32): Int32 // 此处返回类型和 C 侧声明一致
main() {
var a: Int32 = 0x12345678
var b: Int32 = 0
var res: Int32 = unsafe { add(a, b) }
unsafe {
var cstr = LibC.mallocCString("res = %x \n")
printf(cstr, res)
LibC.free(cstr)
}
}
FFI.C.3 仓颉侧接收 C 语言传递过来的指针时,如果可能接收到空指针,应在使用前检查是否为 NULL
【级别】要求
【描述】
仓颉编程语言提供 CPointer<T>
类型对应 C 语言的指针 T*
类型,CPointer<T>
可以使用类型名构造一个实例,用来接收 C 语言传递过来的指针类型,这个实例的值初始为空,相当于 C 语言的 NULL
。如果传递过来的是空指针,则在仓颉侧接收到的也是空指针,没有校验就直接使用会造成空指针引用问题。
常见的场景:
- C 语言侧分配内存失败,返回空指针并传递过来;
- C 语言侧函数返回值为
NULL
。
【反例】
没有处理空指针可导致程序崩溃。
//CTest.c
#include<stdio.h>
#include<stdint.h>
#include<stdlib.h>
int *PassInt32PointerToCangjie() {
int *a = (int *)malloc(sizeof(int));
if (a == NULL)
return NULL;
*a = 1234;
return a;
}
void GetInt32PointerFromCangjie(int *a) {
int b = 12;
a = &b;
printf("value of int *a = %d\n", *a);
}
//CJTest.cj
foreign func PassInt32PointerToCangjie(): CPointer<Int32>
foreign func GetInt32PointerFromCangjie(a: CPointer<Int32>): Unit
main() {
var a = unsafe { PassInt32PointerToCangjie() } // 此处从 C 语言接收指针
if (unsafe { a.read() != 2147483647 }) { // a 未校验就直接引用成员函数 read(),可能出现空指针引用
return
}
unsafe { GetInt32PointerFromCangjie(a) }
}
【正例】
指针引用前先进行校验。
foreign func PassInt32PointerToCangjie(): CPointer<Int32>
foreign func GetInt32PointerFromCangjie(a: CPointer<Int32>): Unit
main() {
var a = unsafe { PassInt32PointerToCangjie() } // 此处从 C 语言接收指针
if (a.isNull()) { // 指针接收后先校验
print("pointer is null!\n")
return
}
if (unsafe { a.read() != 2147483647 }) { // a 未校验就直接引用成员函数 read(),可能出现空指针引用
return
}
unsafe { GetInt32PointerFromCangjie(a) }
}
FFI.C.4 资源不再使用时应予以关闭或释放
【级别】要求
【描述】
在仓颉和 C 语言交互时,可能会手动申请内存、句柄等系统资源,这些资源不再使用时应予以关闭或释放。
若需要分配或释放 C 侧的内存,需要在 C 语言侧提供内存分配和释放的接口,在仓颉侧调用对应的接口。若没有封装接口,则需要根据 C 语言规范要求,在 C 语言侧合理使用 free
或者 close
等函数进行释放。
如果是在仓颉侧直接调用 C 语言库函数分配内存,例如 LibC.malloc
等,如果分配内存成功,在使用完后也必须在仓颉侧调用 LibC.free
等内存释放函数来释放内存。
【反例】
仓颉侧自行分配和释放内存。下述示例代码中,使用完 CString
字符串,但之后没有调用相应的释放函数,导致内存泄漏。
foreign func printf(fmt: CString, ...): Int32
main() {
var str = unsafe { LibC.mallocCString("hello world!\n") }
unsafe { printf(str) }
// 使用完后没有释放 str 的内存
}
【正例】
下述示例中,使用完 CString
字符串后及时调用 LibC.free
来释放内存,消除了上述风险。
foreign func printf(fmt: CString, ...): Int32
main() {
var str = unsafe { LibC.mallocCString("hello world!\n") }
unsafe { printf(str) }
unsafe { LibC.free(str) } // 使用完后释放内存
}
【反例】
若 C 侧提供内存释放函数,则需要在仓颉侧进行调用来释放内存。
// CTest.c
#include<stdio.h>
#include<stdint.h>
#include<stdlib.h>
int* SetMem() {
// 分配内存
int* a = (int*)malloc(sizeof(int));
if (a == NULL) {
return NULL;
}
*a = 123;
return a;
}
void FreeMem(int* a) {
// 释放内存
if (a == NULL) {
printf("Pointer a is NULL!\n");
return;
}
free(a);
}
// CJTest.cj
foreign func SetMem(): CPointer<Int32>
main() {
var a: CPointer<Int32> = unsafe { SetMem() }
// do something
// 此处函数直接返回,未调用 C 侧释放函数来释放之前分配的内存
}
【正例】
// CJTest.cj
foreign func SetMem(): CPointer<Int32>
foreign func FreeMem(a: CPointer<Int32>): Unit
main() {
var a: CPointer<Int32> = unsafe { SetMem() }
// do something
unsafe { FreeMem(a) } // 使用完后及时释放内存
a = CPointer<Int32>() // 将 a 置为空
}
【影响】如果资源在结束使用前未正确地关闭或释放,会造成系统的内存泄漏、句柄泄漏等资源泄漏漏洞。如果攻击者可以有意触发资源泄漏,则可能能够通过耗尽资源来发起拒绝服务攻击。
FFI.C.5 禁止访问已经释放过的资源
【级别】要求
【描述】
如果从 C 语言侧接收到的指针已经进行过释放操作,那么禁止在仓颉侧再次使用这些指针的值,也不得再引用负责接收这些指针的变量,否则可能会造成安全问题,如解引用已释放的内存的指针、再次释放这些指针的内存等。
再次使用已释放内存的指针,可能因为访问无效内存导致程序崩溃,建议在释放内存后将指针显式置空,在下次使用前进行判空校验。
【反例】
// CTest.c
#include<stdio.h>
#include<stdint.h>
#include<stdlib.h>
int* SetMem() {
// 分配内存
int* a = (int*)malloc(sizeof(int));
*a = 123;
return a;
}
void FreeMem(int* a) {
// 释放内存
if (a == NULL) {
printf("Pointer a is NULL!\n");
return;
}
free(a);
}
// CJTest.cj
foreign func SetMem(): CPointer<Int32>
foreign func FreeMem(a: CPointer<Int32>): Unit
var a = CPointer<Int32>()
func Foo() {
a = unsafe { SetMem() }
// 指针校验和其它操作
unsafe { FreeMem(a) } // 调用 C 侧 free 之后指针实际不为空,a 为野指针
}
func Foo2() {
if (!a.isNull()) { // 此处判空校验无效,会被绕过
unsafe { a.read(0) } // 此处会被执行,访问非法地址
}
}
main() {
Foo()
Foo2()
}
【正例】
// CJTest.cj
foreign func SetMem(): CPointer<Int32>
foreign func FreeMem(a: CPointer<Int32>): Unit
var a = CPointer<Int32>()
func Foo() {
a = unsafe { SetMem() }
// 使用指针
unsafe { FreeMem(a) }
a = CPointer<Int32>() // 建议使用完后将指针置为空,避免了使用已释放内存的问题。
}
func Foo2() {
if (!a.isNull()) { // 此处校验有效,指针 a 为空,因此不会进入此分支,避免 use after free
unsafe { a.read(0) }
}
}
main() {
Foo()
Foo2()
}
FFI.C.6 外部数据作为 read()
和 write()
函数索引时必须确保在有效范围内
【级别】要求
【描述】
由于仓颉的 CPointer<T>
类的成员函数 read()
和 write()
支持设置索引,因此可能会使用来自外部的数据作为函数的索引。当使用外部数据作为函数索引时,需要确保其在索引的有效范围内, 否则可能会出现越界访问的风险。
【反例】
// CTest.c
#include<stdio.h>
#include<stdint.h>
#include<stdlib.h>
int* PassPointerToCangjie() {
int *p = (int*)malloc(sizeof(int) * 5);
if ( p == NULL) {
return NULL;
}
for (int i = 0; i < 5; i++) {
p[i] = i;
}
return p;
}
void GetPointerFromCangjie(int *a, int len)
{
if ( a == NULL) {
printf("Pointer a is null!\n");
return;
}
for (int i = 0; i < len; i++) {
printf("%d ", a[i]);
}
}
// CJTest.cj
foreign func printf(fmt: CString, ...): Int32
foreign func PassPointerToCangjie(): CPointer<Int32>
foreign func GetPointerFromCangjie(a: CPointer<Int32>, len: Int32): Unit
func Foo(index: Int64) {
var a: CPointer<Int32> = unsafe { PassPointerToCangjie() } // 接收的数组指针索引范围为 0-4
if (a.isNull()) {
return
}
var value = unsafe { LibC.mallocCString("%d\n") }
unsafe { printf(value, a.read(index)) } // 此处 index 值为函数入参,有可能为外部输入数据
unsafe { a.write(index, 123) } // 没有校验就直接作为数组索引,可能会导致越界访问
var len: Int32 = 5
unsafe { GetPointerFromCangjie(a, len) }
unsafe { LibC.free(value) }
}
main() {
var index: Int64 = 3
Foo(index) //不会越界
var index2: Int64 = 5
Foo(index2) //发生越界
}
【正例】
// CJTest.cj
foreign func printf(fmt: CString, ...): Int32
foreign func PassPointerToCangjie(): CPointer<Int32>
foreign func GetPointerFromCangjie(a: CPointer<Int32>, len: Int32): Unit
let MAX: Int64 = 4
func Foo(index: Int64) {
var a: CPointer<Int32> = unsafe { PassPointerToCangjie() } // 接收的数组指针索引范围为 0-4
let value = unsafe { LibC.mallocCString("%d\n") }
if (index < 0 || index> MAX) { // 对函数入参进行合理的校验
return
}
unsafe { printf(value, a.read(index)) }
unsafe { a.write(index, 123) }
var len: Int32 = 5
unsafe { GetPointerFromCangjie(a, len) }
unsafe { LibC.free(value) }
}
main() {
var index: Int64 = 3
Foo(index) //不会越界
var index2: Int64 = 5
Foo(index2) //校验不通过,不会发生越界
}
【影响】未对外部数据中的整数值进行限制可能导致拒绝服务,缓冲区溢出,信息泄露,甚至执行任意代码。
FFI.C.7 强制进行指针类型转换时避免出现截断错误
【级别】要求
【描述】
仓颉中的不同指针类型间相互进行强制转换时,需要注意强制类型转换前后内存中的数据是不变的,但可能出现元素的合并和拆分的情况,元素个数也可能因此发生变化,使用者必须充分了解数据的内存分布情况,否则不要使用强制指针类型转换。
【反例】
如下示例,将 Int32 类型指针强制转换成 Int16 型,会将数据截断为低两位和高两位,但内存中的数据实际并没有变化,可以通过成员函数 read()
访问,如 read(0)
访问低两位数据,read(1)
访问高两位数据,元素个数由原来的一个变成了两个,并且都可以通过索引访问到内存,但访问第二个元素的时候实际上是越界访问内存。
// CTest.c
#include<stdio.h>
#include<stdint.h>
#include<stdlib.h>
int *PassPointerToCangjie() {
int *p = (int *)malloc(sizeof(int));
if (p == NULL)
return NULL;
*p = 0x1234;
return p;
}
foreign func printf(fmt: CString, ...): Int32
foreign func PassPointerToCangjie(): CPointer<Int32>
main() {
var a: CPointer<Int32> = unsafe { PassPointerToCangjie() }
var b: CPointer<Int16> = CPointer<Int16>(a) // 此处将 Int32 类型指针强制转换成 Int16 型
if (b.isNull()) {
print("pointer is null!\n")
return
}
if (unsafe { b.read() != 0 }) {
print("Pointer was cut!\n")
var value = unsafe { LibC.mallocCString("%x\n") }
unsafe { printf(value, b.read(1)) } // read(1) 访问 Int16 的高两位数据,可能造成越界访问
unsafe { LibC.free(value) }
return
}
var value = unsafe { LibC.mallocCString("%x\n") }
unsafe { printf(value, b.read(0)) }
unsafe { LibC.free(value) }
}
【正例】
谨慎使用强制指针类型转换。
foreign func printf(fmt: CString, ...): Int32
foreign func PassPointerToCangjie(): CPointer<Int32>
main() {
var a: CPointer<Int32> = unsafe { PassPointerToCangjie() }
// 删除此处的强制类型转换
if (a.isNull()) {
print("pointer is null!\n")
return
}
if (unsafe { a.read() != 0 }) {
print("Pointer a was cut!\n")
var value = unsafe { LibC.mallocCString("%x\n") }
unsafe { printf(value, a.read(0)) }
unsafe { LibC.free(value) }
return
}
var value = unsafe { LibC.mallocCString("%x\n") }
unsafe { printf(value, a.read(0)) }
unsafe { LibC.free(value) }
}
附录
命令注入相关特殊字符
表 3 shell 脚本中常用的与命令注入相关的特殊字符。
分类 | 符号 | 功能描述 |
---|---|---|
管道 | | | 连结上个指令的标准输出,作为下个指令的标准输入 |
内联命令 | ; | 连续指令符号 |
内联命令 | & | 单一个 & 符号,且放在完整指令列的最后端,即表示将该指令列放入后台中工作 |
逻辑操作符 | $ | 变量替换 (Variable Substitution) 的代表符号 |
表达式 | $ | 可用在 ${} 中作为变量的正规表达式 |
重定向操作 | > | 将命令输出写入到目标文件中 |
重定向操作 | < | 将目标文件的内容发送到命令当中 |
反引号 | ` 对 | 可在 ` 和 ` 之间构造命令内容并返回当前执行命令的结果 |
倒斜线 | \ | 在交互模式下的 escape 字元,有几个作用;放在指令前,有取消 aliases 的作用;放在特殊符号前,则该特殊符号的作用消失;放在指令的最末端,表示指令连接下一行 |
感叹号 | ! | 事件提示符 (Event Designators), 可以引用历史命令 |
换行符 | \n | 可以用在一行命令的结束,用于分隔不同的命令行 |
上述字符也可能以组合方式影响命令拼接,如管道符 “||”,“>>” ,“<<”,逻辑操作符 “&&” 等,由于基于单个危险字符的检测可以识别这部分组合字符,因此不再列出。另外可以表示账户的 home 目录 “~”,可以表示上层目录的符号“..”,以及文件名通配符 “?” (匹配文件名中除 null 外的单个字元),“*” (匹配文件名的任意字元) 由于只影响命令本身的语义,不会引入额外的命令,因此未列入命令注入涉及的特殊字符,需根据业务本身的逻辑进行处理。
可能产生整数溢出的数学操作符
操作符 | 溢出 | 操作符 | 溢出 | 操作符 | 溢出 | 操作符 | 溢出 |
---|---|---|---|---|---|---|---|
+ | Y | -= | Y | << | N | < | N |
- | Y | *= | Y | >> | N | > | N |
* | Y | /= | Y | & | N | >= | N |
/ | Y | %= | N | | | N | <= | N |
% | N | <<= | N | ^ | N | == | N |
++ | Y | >>= | N | ~ | N | ||
-- | Y | &= | N | ! | N | ||
= | N | \|= | N | != | N | ||
+= | Y | ^= | N | ** | Y |
基础类型映射关系表
CangJie Type | C Type | Size |
---|---|---|
Unit | void | 0 |
Bool | bool | 1 |
Int8 | int8_t | 1 |
UInt8 | uint8_t | 1 |
Int16 | int16_t | 2 |
UInt16 | uint16_t | 2 |
Int32 | int32_t | 4 |
UInt32 | uint32_t | 4 |
Int64 | int64_t | 8 |
UInt64 | uint64_t | 8 |
IntNative | - | * platform dependent |
UIntNative | - | * platform dependent |
Float32 | float | 4 |
Float64 | double | 8 |
struct | struct | field dependent |