【仓颉语言入门 · 第二篇 第 5 课】
【仓颉语言入门 · 第二篇 第 5 课】加密与哈希:给数据盖上防伪印章(std.crypto)
上一课结尾留了个话头:随机数管"不可预测",这一课的
std.crypto管恰恰相反的东西——不可逆。为什么网站的数据库泄露了,你的密码却还安全?为什么下载完一个几 GB 的安装包,官网要你核对一串"天书"字符?这背后都是哈希摘要在干活。这一课把"摘要"这件事讲透,顺手给第 4 课 logpick 的日志文件配一个完整性校验工具 logsum:谁偷偷改过日志,一跑便知。
本文所有代码、输出与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行(凡数值取决于读者本地文件内容的输出,文中会明确标注"示例")。
目录(第二篇导航)
第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 17 课、4 个模块:
| 模块 | 课次 | 内容 |
|---|---|---|
| 一、补遗篇 | 01~06 | 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理 |
| 二、数据库篇 | 07~10 | std.database.sql 入门与 CRUD、预编译与事务、手写 DAO、综合小实战 |
| 三、进阶硬核篇 | 11~14 | 反射、宏与 deriving、C 互操作 FFI、并发容器与对象池 |
| 四、实战项目篇 | 15~17 | HTTP 服务端、多线程日志分析器、简易爬虫 |
- 正则表达式(std.regex)
- 时间日期处理(std.time)
- 命令行参数解析(std.argopt)
- 随机数与概率(std.random)
- 加密与哈希(本文)
- 二进制数据处理(std.binary)
- 数据库入门:std.database.sql 与 CRUD
- 预编译、事务与防注入
- 手写 DAO 数据访问层
- 数据库综合小实战
- 反射:在运行时打量类型
- 宏与 deriving:让编译器帮你写代码
- 与 C 互操作:FFI
- 并发容器与对象池
- 实战三:HTTP 服务端
- 实战四:多线程日志分析器
- 实战五:简易爬虫
一、为什么需要哈希
1.1 密码为什么不能明文存
想象你在维护一个用户系统。最省事的做法是把密码原样存进数据库——明文存储。后果是:数据库一旦泄露(拖库、备份文件外流、运维误操作),所有用户的密码裸奔。更糟的是,多数人多个网站用同一个密码,泄露一处等于泄露一片。
业界的做法:存密码的哈希值,不存密码本身。注册时算一次哈希存起来;登录时把你输入的密码再算一次哈希,两个哈希值一对——相等就是对的。数据库里躺着的只是一串"摘要",即使被拖走,攻击者也拿不到原密码(想反推回去,只能靠暴力穷举,这就是另一场攻防战了)。
这要求摘要算法有个关键性质:从哈希值反推不出输入(单向性)。
1.2 下载的文件为什么不放心
从网上下载安装包,官网旁边常常挂着一串 SHA-256: 3f7a...c9。它的作用是完整性校验:官网算好文件的哈希值公布出来,你下载后在本地把文件再算一遍,两边一致,说明传输过程一个字节都没坏、也没被中间人替换过。
日志文件同理:运维把日志归档保存,过些天回头查——怎么保证这几天没人(或没有进程)偷偷改过它?归档时记下哈希值,核查时再算一遍,一比便知。本文实操部分就把这件事做出来。
1.3 好摘要的三条铁律
把任意长度的数据压成一段定长"指纹"的函数,叫哈希函数(也叫摘要函数、散列函数)。实用的哈希函数有三条铁律:
| 铁律 | 含义 | 对应场景 |
|---|---|---|
| 单向性 | 从指纹反推不出原文 | 密码存储 |
| 定长输出 | 输入 1 字节还是 1 GB,指纹长度固定 | 统一存储、快速比对 |
| 雪崩效应 | 输入改一个比特,指纹面目全非 | 完整性校验、防篡改 |
雪崩效应马上就能亲手验证——我们会写一个自己的摘要算法,改输入一个字母,看输出怎么变。
1.4 先认人:std.crypto 的家底
std.crypto 模块分两个包:
| 包 | 提供 | 作用 |
|---|---|---|
std.crypto.digest |
Digest 接口 + 全局 digest 函数 |
摘要(单向,不可逆) |
std.crypto.cipher |
BlockCipher 接口 |
块加密(双向,可逆) |
注意包的定位是"通用接口":它规定了摘要算法、块加密算法各自要长什么样,把"能算什么"的口子留出来。SDK 1.2.0 的桌面版里没有内置现成的 MD5、SHA-256 等算法类——写 MD5() 直接报 undeclared identifier 'MD5'(实测)。于是这一课我们做一件更有教学价值的事:亲手实现一个真实可用的摘要算法 FNV-1a,把 Digest 接口吃透——"哈希是怎么算出来的"这道黑盒,也就此打开。算法类何时补齐、怎么用,FAQ 里细说。
二、手写第一个摘要算法:FNV-1a
2.1 算法思路:两行核心
FNV-1a 是真实世界在用的哈希算法(数据库、缓存系统里常见),它的核心只有两行:
初始值 h = 14695981039346656037
对输入的每个字节 b:
h = h XOR b ← 先异或
h = h × 1099511628211 ← 再乘一个大质数(超出部分直接丢弃)
- 异或让每个字节"搅进"当前状态;
- 乘大质数让影响扩散到所有位——这正是雪崩效应的来源;
- h 是 64 位无符号整数,乘法会溢出,溢出的高位直接扔掉(这叫"环绕",不是错误)。
全部算完,h 就是摘要。输入任意长,输出恒定 8 字节(64 位)。
2.2 实现 Digest 接口
std.crypto.digest 的主角是 Digest 接口,7 个成员一览:
| 成员 | 签名 | 作用 |
|---|---|---|
write |
func write(buffer: Array<Byte>): Unit |
喂进一段字节,推进内部状态 |
finish |
func finish(): Array<Byte> |
结算,取出摘要(接口另有 finish(to!: 填充版,3.4 节讲) |
reset |
func reset(): Unit |
清零内部状态,从头再算 |
algorithm |
prop algorithm: String |
算法名,自我介绍用 |
blockSize |
prop blockSize: Int64 |
信息块长度(字节) |
size |
prop size: Int64 |
摘要长度(字节) |
实现它(完整可编译,import std.overflow.* 是为 2.3 节的环绕乘法,先别删):
import std.crypto.digest.*
import std.overflow.*
// FNV-1a 64 位摘要算法
class Fnv <: Digest {
var h: UInt64 = 0xcbf29ce484222325 // 初始值(14695981039346656037 的十六进制写法)
public func write(buffer: Array<Byte>): Unit {
for (b in buffer) {
h = h ^ UInt64(b)
h = h.wrappingMul(0x100000001b3) // 环绕乘法:溢出直接扔掉,见 2.3
}
}
public func finish(): Array<Byte> {
var v = h
let out = Array<Byte>(8, repeat: 0)
for (i in 0..8) {
out[i] = UInt8(v & 0xFF) // 取最低 1 字节
v = v >> 8 // 右移,取下一字节
}
return out
}
public func finish(to!: Array<Byte>): Unit {
var v = h
for (i in 0..to.size) {
to[i] = UInt8(v & 0xFF)
v = v >> 8
}
}
public func reset(): Unit {
h = 0xcbf29ce484222325
}
public prop algorithm: String {
get() { "fnv1a-64" }
}
public prop blockSize: Int64 {
get() { 64 }
}
public prop size: Int64 {
get() { 8 }
}
}
逐个说清容易卡壳的点:
var h: UInt64 = 0xcbf29ce484222325——摘要算法的"状态"就是一个 64 位无符号整数。十六进制写法0x...在仓颉直接可用。finish()里的out[i] = UInt8(v & 0xFF)是标准的"拆字节"手法:& 0xFF留下最低 8 位,>> 8右移腾出下一字节。8 次循环正好把 64 位拆成 8 个字节。finish(to!:)是接口要求的第二个重载:不新建数组,把摘要直接填进调用方给的数组。实现接口时两个finish都得写,漏一个编译器不答应(实测报错见下)。blockSize对 FNV 来说没有真正的分块概念,返回 64 表示"逐字节处理"的口径,只作信息展示。
2.3 两个实测才有的坑
坑一:乘法溢出会炸。仓颉的整数运算默认开启溢出检查,把 h.wrappingMul(...) 写成普通的 h * 0x100000001b3,喂几个字节就抛异常(实测):
An exception has occurred:
OverflowException: mul
at cjprobe05::Fnv::write(std.core::Array<...>)
at std.crypto.digest::digest<...>(T0, std.io::InputStream)
FNV 恰恰需要溢出环绕,所以用 std.overflow 包提供的扩展方法 wrappingMul(环绕乘法):溢出的高位直接丢弃,不报错。哈希、加密这类算法几乎都要它。
顺带一提,别想着写 &*——仓颉 1.2.0 没有这个运算符(实测 error: expected expression after '&', found '*')。
坑二:接口参数名要一字不差。finish(to!:) 的 to 是命名参数,实现时写成位置参数 finish(to: Array<Byte>),编译直接拦下(实测):
error: parameter name mismatched
|
23 | public func finish(to: Array<Byte>): Unit {
| ^^^^^^ ~~ this positional parameter conflicts with the corresponding named parameter in the supertype
把签名改成 finish(to!: Array<Byte>): Unit 即可。同理,漏实现接口成员时报(实测,漏写两个 finish 之一):
error: class 'Fnv' missing abstract modifier, otherwise abstract function or property should be implemented
note: unimplemented interface function 'finish'
2.4 三步式使用:write → finish → reset
接口的价值在于统一用法。任何摘要算法,使用套路都是三步:
import std.crypto.digest.*
import std.overflow.*
main(): Int64 {
let d = Fnv()
d.write([104, 101, 108, 108, 111]) // "hello" 的 ASCII 字节
let h = d.finish()
var line = ""
for (b in h) {
line = line + "${b} "
}
println("hello 的摘要: ${line}")
// reset 后可以复用同一个实例算下一个
d.reset()
d.write([104, 101, 108, 108, 111])
let h2 = d.finish()
println("重算一致: ${h == h2}")
0
}
hello 的摘要: 11 189 170 128 70 216 48 164
重算一致: true
"hello" 每次算出来都是这 8 个字节——固定输入、固定输出,这就是摘要算法和第 4 课 Random() 的本质区别:它一点也不随机。
2.5 雪崩效应:改一个字母试试
把 "hello" 的最后一个字母 o 换成 p(111 → 112),只动一个比特:
import std.crypto.digest.*
import std.overflow.*
main(): Int64 {
let d = Fnv()
d.write([104, 101, 108, 108, 111]) // hello
println("hello: ${d.finish()}")
d.reset()
d.write([104, 101, 108, 108, 112]) // hellp
println("hellp: ${d.finish()}")
0
}
hello: [11, 189, 170, 128, 70, 216, 48, 164]
hellp: [34, 211, 170, 128, 70, 229, 48, 164]
前 4 个字节全变了。真宗的加密级算法(SHA-256 等)雪崩更彻底——64 字节的摘要几乎每个比特都会翻转。但"输入变一点、输出大不同"这个性质,FNV-1a 已经演得很清楚。
小提示:
finish()返回的字节顺序是"低位在前"。同一输入,你在别的语言或在线工具里算 FNV-1a,可能看到数字相同但顺序相反的 8 字节——那只是字节序习惯不同,不影响"同输入同输出"的校验用途。本课所有哈希值都以本文输出顺序为准。
三、digest 函数:一行算摘要
每次都 write/finish 三步太啰嗦,std.crypto.digest 提供全局函数 digest 直接一步到位。它有三种输入形态,实测结论先列表:
| 形态 | 写法 | 状态 |
|---|---|---|
| 字节数组 | digest(d, byteArray) |
推荐,无警告 |
| 字符串 | digest(d, "hello") |
能跑,但有废弃警告 |
| 输入流 | digest(d, stream) |
推荐,文件场景主力(第六节) |
3.1 字节数组:最常用
这里有个实测的坑:不能把数组字面量直接递给 digest。digest 有三个重载(字节数组 / 字符串 / 输入流),裸字面量 [104, ...] 会被推断成 Array<Int64>,三个重载一个都对不上,编译器当场报错(实测原文):
error: unable to infer generic argument of this function
note: for function type '(Generics-T, Struct-String) -> Struct-Array<UInt8>' with constraint 'Generics-T <: Interface-Digest'
note: mismatched types
... of type 'Struct-Array<Int64>', impossible to match parameter type 'Struct-String'
先声明成 Array<Byte> 再传,编译器就有了明确的匹配对象:
import std.crypto.digest.*
import std.overflow.*
main(): Int64 {
let data: Array<Byte> = [104, 101, 108, 108, 111]
let h = digest(Fnv(), data)
var line = ""
for (b in h) {
line = line + "${b} "
}
println("摘要: ${line}")
0
}
摘要: 11 189 170 128 70 216 48 164
和 2.4 节三步式的结果一字不差——digest 函数内部替你做了 write + finish。
3.2 字符串:能用,但警告你别用
let h = digest(Fnv(), "hello")
编译能过、运行也对,但编译器给出废弃警告(实测原文):
warning: function 'digest' is deprecated. Use global function `public func digest<T>(algorithm: T, input: InputStream): Array<Byte> where T <: Digest` instead.
原因不难猜:字符串算摘要前得先定编码(UTF-8?GBK?),标准库把"编码"这层不确定性关在了门外——想算字符串,先自己 .toArray() 转成字节数组,编码意图清清楚楚。这个习惯值得保持。
3.3 输入流:为文件而生
第三种形态接收 InputStream(std.io 的输入流接口),digest 会自动循环读取、喂给算法、读完结算。内存里的字节用 ByteBuffer 装一下就能试:
import std.crypto.digest.*
import std.io.*
import std.overflow.*
main(): Int64 {
let data: Array<Byte> = [104, 101, 108, 108, 111]
let stream = ByteBuffer(data) // 把字节数组包成输入流
let h = digest(Fnv(), stream)
var line = ""
for (b in h) {
line = line + "${b} "
}
println("流式摘要: ${line}")
0
}
流式摘要: 11 189 170 128 70 216 48 164
三种形态殊途同归。流式版本真正的威力在第六节——文件直接当流喂进去,几 GB 的大文件也不用整个装进内存。
四、字节 → 十六进制:指纹的"人话"形态
finish() 返回的是字节数组,2.5 节直接打印出来是 [11, 189, 170, 128, ...] 这样一串数。而官网公布的校验和是 3f7a...c9 这种十六进制字符串——每个字节两位、0~9 加 a~f。补一个转换函数(本课后续所有校验和都按这个格式展示):
func toHex(bytes: Array<Byte>): String {
let hex = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]
var s = ""
for (b in bytes) {
s = s + hex[Int64(b >> 4)] + hex[Int64(b & 0xF)]
}
return s
}
一个字节 8 位,高 4 位(b >> 4)和低 4 位(b & 0xF)各自落在 0~15,查表拼出两个十六进制字符。8 字节摘要正好 16 个字符。
为什么查表而不是拿字符做加法?这里有个实测的坑:String 的下标取出来的是字节值,不是字符——"0123456789abcdef"[10] 打印出来是 97(字母 a 的 ASCII 码),不是 a。想拿"下标第 10 个字符",查 Array<String> 表最省心。
后续课程会学到
std.binary(第 6 课)里更完整的编码工具,这个手写版帮你把"字节 ↔ 十六进制"的原理一次弄懂。
五、文件完整性校验:流式哈希
5.1 文件直接当输入流
std.fs 的 File 打开后本身就是 InputStream,可以直接喂给 digest:
import std.crypto.digest.*
import std.io.*
import std.fs.*
import std.overflow.*
func toHex(bytes: Array<Byte>): String {
let hex = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]
var s = ""
for (b in bytes) {
s = s + hex[Int64(b >> 4)] + hex[Int64(b & 0xF)]
}
return s
}
main(): Int64 {
// 流式:边读边算
let f = File("data.txt", OpenMode.Read)
let h1 = digest(Fnv(), f)
f.close()
println("文件流哈希: ${toHex(h1)}")
// 对照:全部读进内存再算
let content = File.readFrom("data.txt")
let h2 = digest(Fnv(), content.toArray())
println("内存哈希: ${toHex(h2)}")
println("一致性: ${toHex(h1) == toHex(h2)}")
0
}
在工程根目录放一个 data.txt(内容 hello cangjie)后运行(输出数值取决于文件内容,你的文件内容与本文不同,数值自然不同):
文件流哈希: a2dfcd085fbe4d75
内存哈希: a2dfcd085fbe4d75
一致性: true
两条路殊途同归——流式读到的字节和内存里的字节相同,摘要必然相同。
5.2 为什么要"流式"
File.readFrom 是第 22 课的老朋友:一次性把整个文件读进内存。校验和日志这种小文件没问题,换成 2 GB 的安装包就难受了——内存里同时躺着"完整文件 + 计算过程",翻倍开销。
流式版本 digest(Fnv(), f) 的内存占用只有"一个 64 位状态 + 一小块读缓冲",文件多大都不怕。官网校验几个 GB 的安装包用的就是这种思路。这就是 digest 三种形态里输入流版本存在的意义。
5.3 篡改检测:改一个字节试试
校验和的用武之地:先算好摘要记下来;之后任意时刻重算,一比就知道文件动没动。动一个字节,摘要完全对不上(2.5 节的雪崩效应保证这一点)。把这个"算 → 记 → 比"的流程固化成命令行工具,就是第八节的 logsum。
六、可逆的那一半:BlockCipher 与 XOR 块加密
哈希是单向的:摘要变不回原文。加密是双向的:用密钥加密、再用密钥解回来。std.crypto.cipher 提供块加密的统一接口 BlockCipher:
| 成员 | 签名 | 作用 |
|---|---|---|
encrypt |
func encrypt(input: Array<Byte>, to!: Array<Byte>): Int64 |
加密:结果写进 to,返回写入长度 |
decrypt |
func decrypt(input: Array<Byte>, to!: Array<Byte>): Int64 |
解密:同上 |
algorithm |
prop algorithm: String |
算法名 |
blockSize |
prop blockSize: Int64 |
块长度(字节) |
两个要点:一是 to!: 是命名参数——结果不返回新数组,而是填进你给的缓冲区,返回的 Int64 是写入字节数,这是"调用方管内存"的设计;二是和 Digest 一样,1.2.0 桌面版只有接口没有现成算法类,我们手写一个教学用的 XOR 块加密(真实 AES 在数学上复杂得多,但"块进块出"的接口形状一模一样):
import std.crypto.cipher.*
// 8 字节 XOR 块加密:加密和解密是同一个运算
class XorCipher <: BlockCipher {
let key: Array<Byte>
init(key: Array<Byte>) {
this.key = key
}
public func encrypt(input: Array<Byte>, to!: Array<Byte>): Int64 {
for (i in 0..input.size) {
to[i] = input[i] ^ key[i % key.size]
}
return to.size
}
public func decrypt(input: Array<Byte>, to!: Array<Byte>): Int64 {
return encrypt(input, to: to) // XOR 自反:再异或一次就还原
}
public prop algorithm: String {
get() { "xor-8" }
}
public prop blockSize: Int64 {
get() { 8 }
}
}
main(): Int64 {
let c = XorCipher([0x5a, 0xa5, 0x3c, 0xc3, 0x0f, 0xf0, 0x66, 0x99])
let plain: Array<Byte> = [104, 101, 108, 108, 111] // "hello"
let encBuf = Array<Byte>(5, repeat: 0)
c.encrypt(plain, to: encBuf)
print("密文: ")
for (b in encBuf) {
print("${b} ")
}
println()
let decBuf = Array<Byte>(5, repeat: 0)
c.decrypt(encBuf, to: decBuf)
print("解密: ")
for (b in decBuf) {
print("${b} ")
}
println()
println("往返一致: ${decBuf == plain}")
println("algo=${c.algorithm} blockSize=${c.blockSize}")
0
}
密文: 50 192 80 175 96
解密: 104 101 108 108 111
往返一致: true
algo=xor-8 blockSize=8
XOR 有个漂亮的数学性质:a XOR k XOR k = a——同一个密钥异或两次等于没动过。所以 decrypt 直接复用 encrypt。明文 104 101 108 108 111("hello")绕一圈原样回来,这就是加密与哈希的分水岭:
| 哈希(Digest) | 加密(BlockCipher) | |
|---|---|---|
| 方向 | 单向,摘要回不去原文 | 双向,密钥解回原文 |
| 密钥 | 不需要 | 需要 |
| 长度 | 恒定(如 8 字节) | 与明文相关 |
| 用途 | 校验、存密码、指纹 | 保密传输、存储加密 |
实现接口时的报错也实测过两处,照抄会踩:encrypt 的返回类型写 Array<Byte> 报 error: return type of 'encrypt' is not identical or not a subtype of the overridden/redefined/implement function(接口要求 Int64);漏实现则报 2.3 节同款 missing abstract modifier。
七、CIDE 实操:logsum 日志完整性校验
第 3 课 logtool 管"过滤",第 4 课 logpick 管"抽查",本课给日志家族添最后一块拼图 logsum:给日志文件算校验和、存档,之后随时复核——日志有没有被人动过手脚,一跑便知。
用法设计(argopt 是第 3 课的功底):
-i 日志路径:选填,默认data/app.log;--gen:计算校验和,写入日志路径.sum文件;--check:重新计算,与.sum文件里记录的值比对。
7.1 数据文件
沿用第 3、4 课的 data/app.log(放在工程根目录):
2026-10-09 08:30:15 [INFO] auth - 用户 user01 从 192.168.1.23 登录
2026-10-09 08:31:02 [ERROR] db - 连接超时: 192.168.1.10:5432
2026-10-09 08:31:45 [WARN] auth - 密码重试过多: user07
2026-10-09 08:32:20 [INFO] api - 请求 /api/orders 耗时 42ms
2026-10-09 08:33:05 [ERROR] api - 调用支付网关失败: 10.0.0.8
2026-10-09 08:34:10 [INFO] auth - 用户 13812345678 修改了手机号 13998887766
7.2 完整代码
src/main.cj 全文(Fnv 是第二节的手写摘要,toHex 是第四节,argopt 是第 3 课,文件读写是第 22 课):
import std.crypto.digest.*
import std.fs.*
import std.overflow.*
import std.argopt.*
import std.unicode.*
import std.collection.*
class Fnv <: Digest {
var h: UInt64 = 0xcbf29ce484222325
public func write(buffer: Array<Byte>): Unit {
for (b in buffer) {
h = h ^ UInt64(b)
h = h.wrappingMul(0x100000001b3)
}
}
public func finish(): Array<Byte> {
var v = h
let out = Array<Byte>(8, repeat: 0)
for (i in 0..8) {
out[i] = UInt8(v & 0xFF)
v = v >> 8
}
return out
}
public func finish(to!: Array<Byte>): Unit {
var v = h
for (i in 0..to.size) {
to[i] = UInt8(v & 0xFF)
v = v >> 8
}
}
public func reset(): Unit {
h = 0xcbf29ce484222325
}
public prop algorithm: String {
get() { "fnv1a-64" }
}
public prop blockSize: Int64 {
get() { 64 }
}
public prop size: Int64 {
get() { 8 }
}
}
func toHex(bytes: Array<Byte>): String {
let hex = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"]
var s = ""
for (b in bytes) {
s = s + hex[Int64(b >> 4)] + hex[Int64(b & 0xF)]
}
return s
}
// 计算文件哈希:流式读取,边读边算
func fileHash(path: String): Array<Byte> {
let f = File(path, OpenMode.Read)
let h = digest(Fnv(), f)
f.close()
return h
}
main(args: Array<String>): Int64 {
let specs = [
ArgumentSpec.Full("input", r'i', ArgumentMode.RequiredValue),
ArgumentSpec.Long("gen", ArgumentMode.NoValue),
ArgumentSpec.Long("check", ArgumentMode.NoValue)
]
try {
let pa = parseArguments(args, specs)
var path = "data/app.log"
if (let Some(p) <- pa.options.get("input")) {
path = p
}
let gen = pa.options.get("gen").isSome()
let check = pa.options.get("check").isSome()
if (!gen && !check) {
println("用法:logsum [-i 日志路径] --gen 或 --check")
return 1
}
let sumPath = path + ".sum"
if (gen) {
let hex = toHex(fileHash(path))
let f = File(sumPath, OpenMode.Write)
f.write(hex.toArray())
f.close()
println("已生成校验和:${sumPath}")
println("校验和:${hex}")
} else {
if (!exists(Path(sumPath))) {
println("找不到校验和文件 ${sumPath},请先运行 --gen")
return 1
}
let now = toHex(fileHash(path))
let saved = String.fromUtf8(File.readFrom(sumPath)).trim()
if (now == saved) {
println("校验通过:文件完好")
} else {
println("警告:文件已被修改!")
println("记录值:${saved}")
println("当前值:${now}")
}
}
} catch (e: ArgumentParseException) {
println("参数错误:${e.message}")
return 1
} catch (e: Exception) {
println("运行出错:${e.message}")
return 1
}
0
}
7.3 六种运行场景
全部实测(cwd 为工程根目录)。下面的校验和数值是本文环境文件字节算出来的"示例"——你创建的 app.log 若编码或换行符与本文不同,数值会不同,但 gen/check 的行为完全一致。
场景一:生成校验和——--gen 算出摘要,写进 data/app.log.sum:
cjpm run -- --gen
已生成校验和:data/app.log.sum
校验和:c905b869ffbacf9d
场景二:文件没动过,--check 放行:
cjpm run -- --check
校验通过:文件完好
场景三:篡改检测。把日志第 5 行的攻击者 IP 从 10.0.0.8 改成 10.0.0.9(删除日志里的作案痕迹——真实世界里篡改者就是这么想的),再复核:
cjpm run -- --check
警告:文件已被修改!
记录值:c905b869ffbacf9d
当前值:cc7fc5b0296c4922
一个字符之差,两个校验和天壤之别——雪崩效应在实战里兑现了。场景四:不带 --gen 也不带 --check,提示用法:
cjpm run
用法:logsum [-i 日志路径] --gen 或 --check
场景五:.sum 文件被删了(或还没生成过):
cjpm run -- --check
找不到校验和文件 data/app.log.sum,请先运行 --gen
场景六:日志文件本身不存在(--gen 一个没有的文件),File 构造抛异常被兜底 catch 接住:
cjpm run -- --gen -i data/nope.log
运行出错:The file `C:\Users\zx\AppData\Local\Temp\cjprobe05\data/nope.log` does not exist or permission denied!
(路径前缀是你的工程所在目录,报错核心文案一致。)
7.4 逐段看懂
- specs:
-i是RequiredValue(要路径),--gen/--check是ArgumentMode.Long+NoValue纯开关——出现了就是Some,用.isSome()判断,不看值(第 3 课的开关型套路)。 - fileHash:把第五节的流式哈希封成三行函数。
Fnv()每次新建实例——摘要状态是私有的,用完即弃最干净;要复用就得reset()。 - gen 分支:
File(sumPath, OpenMode.Write)覆盖写(第 22 课:不存在则创建,存在则清空重写),hex.toArray()把校验和字符串转字节落盘。写完必须close(),否则内容可能还躺在缓冲区里。 - check 分支:先
exists(Path(sumPath))判文件存在(友好提示比异常栈体面),再读回.sum内容trim()后比对。先检查依赖、再走主逻辑,第 4 课 logpick 的老规矩。 - 两个 catch:
ArgumentParseException管参数配对错误;Exception兜底一切运行期异常(文件不存在就在这里被接住)。catch 必须带类型,第 10 课讲过的规矩。
八、常用 API 速查
| API | 说明 |
|---|---|
class Fnv <: Digest |
自己实现摘要:实现 write/finish/finish(to!:)/reset + 三个 prop |
d.write(bytes) / d.finish() / d.reset() |
三步式:喂字节 → 取摘要 → 复位复用 |
digest(d, byteArray) |
一步算摘要(字节数组,推荐) |
digest(d, stream) |
流式摘要(ByteBuffer、File 都是 InputStream) |
digest(d, "字符串") |
能跑但有废弃警告——先 .toArray() 再算 |
h.wrappingMul(x) |
环绕乘法(std.overflow),溢出丢弃高位;哈希/加密必备 |
toHex(bytes) |
字节转十六进制(本文手写版,查表法) |
class XorCipher <: BlockCipher |
块加密:实现 encrypt/decrypt(input, to!): Int64 + 两个 prop |
c.encrypt(input, to: buf) |
加密结果写进 buf,返回写入长度 |
一张表分清两大接口:
Digest(digest 包) |
BlockCipher(cipher 包) |
|
|---|---|---|
| 方向 | 单向摘要 | 双向加密 |
| 输出 | finish() 返回定长摘要 |
encrypt(input, to!) 填缓冲区,返回长度 |
| 1.2.0 现成算法类 | 无(自己实现) | 无(自己实现) |
九、常见问题 FAQ
Q1:MD5()、SHA256() 为什么报 undeclared identifier?官方文档明明写了有这些类?
你看到的文档多半是 OpenHarmony(鸿蒙)版仓颉 SDK 的——那边确实内置了 MD5、SHA1、SHA256、SM3 等算法类。桌面版 SDK 1.2.0 的 std.crypto 只有 Digest/BlockCipher 接口和 digest 函数,没有内置算法类(实测 MD5() 报 undeclared identifier 'MD5')。本课手写 FNV-1a 正是为绕开这个差异、顺便讲透原理;等桌面版补齐算法类后,接入方式就是"实现同一个 Digest 接口"的官方版,用法不变。
Q2:digest(d, "hello") 明明能跑,为什么 IDE 一直画黄线?
那是废弃警告(实测原文):warning: function 'digest' is deprecated. Use global function ... instead.。字符串算摘要涉及编码选择,标准库把它关在门外——先用 .toArray() 转字节数组再算,警告消失,语义也更明确。
Q3:算法里用普通 * 乘法,为什么跑着跑着抛 OverflowException: mul?
仓颉整数运算默认开溢出检查,溢出即抛异常。摘要算法需要"溢出环绕"语义,用 std.overflow 包的扩展方法:import std.overflow.* 后写 h.wrappingMul(x)。哈希、校验和这类算法几乎都要它。
Q4:&*、&+ 这种环绕运算符仓颉有吗?其他语言里很常见。
1.2.0 没有这类运算符,实测 h &* x 报 error: expected expression after '&', found '*'。环绕运算统一走 std.overflow 的函数形式(wrappingAdd/wrappingMul 等)。
Q5:"0123456789abcdef"[10] 打印出来为什么是 97 不是 a?
String 的下标取出的是该位置的字节值(UTF-8 字节),不是字符。字母 a 的 ASCII 码是 97。做字符映射时用 Array<String> 查表(本文 toHex 的写法),别直接对字符串下标做字符串拼接。
Q6:照抄本文命令,我的校验和为什么和文中数值不一样?
校验和是逐字节算出来的:文件编码(带不带 BOM)、换行符(CRLF 还是 LF)、末尾有没有空行,任何一个差异都会改变结果。数值不同不代表你算错了——把你的 --gen 和 --check 前后对照,行为一致(未篡改通过、篡改后报警)就是对的。
十、课后练习
规矩不变:前 4 题必做,第 5 题选做。每题只考本课 1~2 个知识点,题面给全函数签名和 API 提示,先看懂题再动笔。
第 1 题(必做)空串与单字节的摘要
用本课的 Fnv 类,分别计算空字节数组 [] 和单字节 [104] 的摘要并打印。要求用 digest 函数一步算出。
提示:digest(Fnv(), Array<Byte>());[104] 要先标注类型再传——let one: Array<Byte> = [104](3.1 节的坑:digest 有多个重载,裸字面量推断不出)。直接用插值打印数组,输出自带方括号。空数组也返回 8 字节——摘要长度只跟算法有关,跟输入无关。
import std.crypto.digest.*
import std.overflow.*
main(): Int64 {
// 补全:分别计算 [] 和 [104] 的摘要并打印
0
}
期望输出:
[]: [37, 35, 34, 132, 228, 156, 242, 203]
[104]: [215, 251, 1, 134, 76, 229, 99, 175]
第 2 题(必做)读程序写输出
不运行程序,判断 println 输出 true 还是 false,再运行验证。
import std.crypto.digest.*
import std.overflow.*
main(): Int64 {
let d = Fnv()
d.write([1, 2, 3])
let h1 = d.finish()
let h2 = d.finish()
d.reset()
d.write([1, 2, 3])
let h3 = d.finish()
println("h1 == h2: ${h1 == h2}")
println("h1 == h3: ${h1 == h3}")
0
}
期望输出:
h1 == h2: true
h1 == h3: true
要点:finish() 只读状态不清状态,连调两次结果相同;reset() 之后重喂相同字节,摘要复现。
第 3 题(必做)大写十六进制
仿照本文 toHex,写一个 toHexUpper,把字节转成大写十六进制(如 255 → FF)。用 [222, 202, 79] 验证。
提示:把查表数组换成大写字母版即可;222 拆成高 4 位 13、低 4 位 14,对应 D、E;202 对应 C、A;79 对应 4、F。
func toHexUpper(bytes: Array<Byte>): String {
// 补全:查表法,输出大写十六进制
}
期望输出:
DECA4F
第 4 题(必做)换把密钥再加密
用本课的 XorCipher,密钥换成 [1, 2, 3, 4, 5, 6, 7, 8],对 [104, 101, 108]("hel")加密后再解密,打印密文、明文和往返一致性。
提示:照抄第六节 main 的套路,只换 key 与 plain;encrypt/decrypt 的缓冲区大小要跟明文一致(3 字节)。
import std.crypto.cipher.*
main(): Int64 {
let c = XorCipher([1, 2, 3, 4, 5, 6, 7, 8])
let plain: Array<Byte> = [104, 101, 108]
// 补全:加密 → 打印密文 → 解密 → 打印明文与一致性
0
}
期望输出:
密文: 105 103 111
解密: 104 101 108
往返一致: true
第 5 题(选做)给 logsum 加静默模式
给 logsum 加一个 --quiet 开关:校验通过时不打印任何内容(脚本场景只想知道退出码),只有发现篡改才打印警告。
提示:specs 里加一条 ArgumentSpec.Long("quiet", ArgumentMode.NoValue);check 分支里"校验通过"的 println 包进 if (!quiet)。
// 在 logsum 的 specs 与 check 分支上补全
期望行为:--check --quiet 且文件完好时无输出;篡改后照常打印警告三行。
下节预告
哈希把字节"搅碎"成指纹,靠的是位运算(XOR、移位、与或非)——这些操作今天只用了一小部分。下一课的 二进制数据处理(std.binary) 把位运算的工具箱全部摊开:字节序、位域、Base64 这类编码转换为什么都归它管?学完你就有了读懂一切"底层格式"的钥匙。
系列说明:本系列基于 Windows 平台 + CIDE + 仓颉 SDK(1.2.0)编写,所有代码均已实际编译运行通过。如遇 SDK 版本差异导致的细节出入,以你本地版本为准,欢迎评论区交流。
💬 遇到问题?扫码联系作者
跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。
📥 工具下载
本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):
- GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
- 打开页面后进入 发行版(Releases),两种包任选其一:
- 安装版:下载
CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用; - 免安装版(Portable):下载
CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
- 安装版:下载
- 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn

浙公网安备 33010602011771号