【仓颉语言入门 · 第二篇 第 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 服务端、多线程日志分析器、简易爬虫
  1. 正则表达式(std.regex)
  2. 时间日期处理(std.time)
  3. 命令行参数解析(std.argopt)
  4. 随机数与概率(std.random)
  5. 加密与哈希(本文)
  6. 二进制数据处理(std.binary)
  7. 数据库入门:std.database.sql 与 CRUD
  8. 预编译、事务与防注入
  9. 手写 DAO 数据访问层
  10. 数据库综合小实战
  11. 反射:在运行时打量类型
  12. 宏与 deriving:让编译器帮你写代码
  13. 与 C 互操作:FFI
  14. 并发容器与对象池
  15. 实战三:HTTP 服务端
  16. 实战四:多线程日志分析器
  17. 实战五:简易爬虫

一、为什么需要哈希

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
posted @ 2026-10-14 00:22  upala  阅读(12)  评论(0)    收藏  举报