// 容器层 · JPEG。
//
// EXIF 住在 APP1 段里,前面顶着 6 字节标识 "Exif\0\0"。麻烦在于 XMP 也住 APP1,
// 只按段号找"第一个 APP1"就把别人的 XMP 一起毁了——所以这里按载荷前缀认人。
//
// 走查在第一个 SOS(熵编码数据起点)处停下:元数据按规范只可能在图像数据之前,
// 而一旦踏进压缩流就没有可靠的方式走出来。停下来之后会对剩余字节做一次
// 元数据标识扫描:如果那里还有一份,宁可报错也不假装删干净了。
///|
/// `FF E1` + 2 字节长度域 + 6 字节标识:TIFF 块之前被占掉的字节数。
let app1_overhead : Int = 10
///|
/// APP1 里标识"这一段是 EXIF"的头。
let exif_head : Array[Byte] = [b'E', b'x', b'i', b'f', b'\x00', b'\x00']
///|
/// APP1 里标识"这一段是 XMP"的头。
let xmp_head : Array[Byte] = [
b'h', b't', b't', b'p', b':', b'/', b'/', b'n', b's', b'.', b'a', b'd', b'o', b'b',
b'e', b'.', b'c', b'o', b'm', b'/', b'x', b'a', b'p', b'/', b'1', b'.', b'0', b'/',
]
///|
/// 分块存放的扩展 XMP 的头。同一份文档可能被切成好几段,每段都顶这个标识。
///
/// 只认标准那一种,等于让另一份副本安安静静地留在文件里,
/// 而报告上说"XMP 已删"。
let xmp_ext_head : Array[Byte] = [
b'h', b't', b't', b'p', b':', b'/', b'/', b'n', b's', b'.', b'a', b'd', b'o', b'b',
b'e', b'.', b'c', b'o', b'm', b'/', b'x', b'm', b'p', b'/', b'e', b'x', b't', b'e',
b'n', b's', b'i', b'o', b'n', b'/',
]
///|
/// APP13 里 Photoshop 资源块的头(含结尾那个 NUL)。
///
/// IPTC-IIM 记录(署名、版权、联系人)就住在这包里,而它和 IFD0 里的
/// `0x83bb` 是同一类东西:整包载体,逐条判断不了,只能整包丢。
let iptc_head : Array[Byte] = [
b'P', b'h', b'o', b't', b'o', b's', b'h', b'o', b'p', b' ', b'3', b'.', b'0', b'\x00',
]
///|
/// 公认"不装元数据"的几种载荷前缀:JFIF/JFXX 的应用标识、ICC 色彩描述子、
/// Adobe 的量化与色彩变换。它们不出声。
///
/// 这份名单是逐条定的,不按段号整段放行:APP0 里也能塞一份真 EXIF,
/// APP2 里除了 ICC 还有 MPF(缩略图链,常常自带坐标)。
let structural_heads : Array[Array[Byte]] = [
[b'J', b'F', b'I', b'F', b'\x00'],
[b'J', b'F', b'X', b'X', b'\x00'],
[b'I', b'C', b'C', b'_', b'P', b'R', b'O', b'F', b'I', b'L', b'E', b'\x00'],
[b'A', b'd', b'o', b'b', b'e', b'\x00'],
[b'A', b'd', b'o', b'b', b'e', b'_', b'C', b'M', b'\x00'],
]
///|
/// JPEG 里的一个段。`offset` 是段首那个 0xff 的位置,`length` 是整段的字节数,
/// 所以段本身是 `[offset, offset+length)`。
pub(all) struct JpegSegment {
marker : Int
offset : Int
length : Int
} derive(Debug, Eq)
///|
pub extend JpegSegment with Eq::{not_equal, equal}
///|
pub extend JpegSegment with @debug.Debug::{to_repr}
///|
/// 段表。
///
/// `exif` 是 APP1 里那段 TIFF 块的 `[起, 止)`;`xmp` 是每一段 APP1/XMP 的位置。
/// 记下来是为了如实交代:XMP 里常是同一批事实的第二份副本,
/// 逐条清单看不见它,脱敏工具最怕让人以为已经删干净了。
pub(all) struct JpegMap {
segments : Array[JpegSegment]
exif : (Int, Int)?
has_xmp : Bool
/// 每一段 APP1/XMP 的 `[起, 止)`,含段码与长度域。
///
/// 走查时才知道确切的边界(段边界之前可能有任意多个 0xff 填充),
/// 所以在这里记下来,删除时不再重新推导一遍。
/// `has_xmp` 与它是同一件事的两种问法:`has_xmp == !xmp.is_empty()`。
/// 分块扩展 XMP 也算在这一项里:那是同一份文档的另一写法,不是另一件事。
xmp : Array[(Int, Int)]
/// APP13 里 Photoshop 资源包(IPTC-IIM 住在里面)的段区间。
///
/// 和 XMP 一样是"整包",条目清单里永远看不见它,所以同样要单独记一项。
iptc : Array[(Int, Int)]
has_iptc : Bool
/// 我们既没读、也不会删的段:逐段点名,不替它编一句"应该没事"。
///
/// 判据是载荷前缀,不是段号——见 `structural_heads` 那一段的理由。
/// 这一项为空的 JPEG,才是"除 EXIF/XMP/IPTC 之外没有别的地方藏东西"。
unread : Array[JpegSegment]
} derive(Debug, Eq)
///|
pub extend JpegMap with Eq::{not_equal, equal}
///|
pub extend JpegMap with @debug.Debug::{to_repr}
///|
/// 走一遍 JPEG 的段表:找到 EXIF 的落点,顺便看清还有没有别的东西。
pub fn jpeg_inspect(data : Bytes) -> JpegMap raise MetaError {
let n = data.length()
let bad_start = n < 2 ||
byte_at(data, 0).to_int() != 0xff ||
byte_at(data, 1).to_int() != 0xd8
if bad_start {
raise NotJpeg(0)
}
let segments : Array[JpegSegment] = [{ marker: 0xd8, offset: 0, length: 2, }]
let mut exif : (Int, Int)? = None
let mut has_xmp = false
let xmp_spans : Array[(Int, Int)] = []
let mut has_iptc = false
let iptc_spans : Array[(Int, Int)] = []
let unread : Array[JpegSegment] = []
let mut p = 2
let mut scan_end = n
let mut done = false
while p < n && !done {
if byte_at(data, p).to_int() != 0xff {
raise TruncatedAt(p)
}
// 段边界之间可以塞任意多个 0xff 填充,跳过它们才看得到真正的段码
let mut k = p + 1
while k < n && byte_at(data, k).to_int() == 0xff {
k = k + 1
}
if k >= n {
raise TruncatedAt(k)
}
let marker = byte_at(data, k).to_int()
if marker == 0xd9 {
// EOI:整个文件到此为止
segments.push({ marker, offset: p, length: k + 1 - p, })
scan_end = k + 1
done = true
} else if standalone_marker(marker) {
segments.push({ marker, offset: p, length: k + 1 - p, })
p = k + 1
} else {
// 变长段:段码之后是 2 字节大端长度,长度域把自己算在内
let seg_len = u16_at(data, k + 1, false)
let left = n - k - 2
if seg_len < 2 || seg_len - 2 > left {
raise SegmentTooLarge(p, seg_len, left)
}
let span = k - p + 1 + seg_len
if marker == 0xda {
// SOS:段头之后就是压缩流,元数据走查到此为止
segments.push({ marker, offset: p, length: span, })
scan_end = p + span
done = true
} else {
let payload = k + 3
let is_xmp = marker == 0xe1 &&
(
starts_with(data, payload, xmp_head) ||
starts_with(data, payload, xmp_ext_head)
)
let is_iptc = marker == 0xed && starts_with(data, payload, iptc_head)
let unreadable = may_hold_metadata(marker) &&
!structural_payload(data, payload)
if marker == 0xe1 && starts_with(data, payload, exif_head) {
match exif {
Some(_) => raise ExtraMetadata(payload)
None => exif = Some((payload + 6, p + span))
}
} else if is_xmp {
has_xmp = true
xmp_spans.push((p, p + span))
} else if is_iptc {
has_iptc = true
iptc_spans.push((p, p + span))
} else if unreadable {
unread.push({ marker, offset: p, length: span, })
}
segments.push({ marker, offset: p, length: span, })
p = p + span
}
}
}
check_no_metadata_after(data, scan_end)
{
segments,
exif,
has_xmp,
xmp: xmp_spans,
has_iptc,
iptc: iptc_spans,
unread,
}
}
///|
/// 可能藏元数据的段号:厂商应用段 APP0–APP15 与注释段 COM。
///
/// 其余段码(SOF/DHT/DQT/SOS/…)是编码结构与参数,不在"可能藏东西"的范围内。
fn may_hold_metadata(marker : Int) -> Bool {
(marker >= 0xe0 && marker <= 0xef) || marker == 0xfe
}
///|
/// 载荷前缀在"公认不装元数据"那份名单上。名单按前缀逐条定,见 `structural_heads`。
fn structural_payload(data : Bytes, at : Int) -> Bool {
for head in structural_heads {
if starts_with(data, at, head) {
return true
}
}
false
}
///|
/// 没有长度域、也不带载荷的段码。
fn standalone_marker(marker : Int) -> Bool {
marker == 0x01 ||
marker == 0xd8 ||
(marker >= 0xd0 && marker <= 0xd7) ||
(marker >= 0x08 && marker <= 0x0f)
}
///|
/// 压缩流之后不许再藏元数据:只处理第一份就是假脱敏。
fn check_no_metadata_after(data : Bytes, from : Int) -> Unit raise MetaError {
let to = data.length()
for head in [exif_head, xmp_head, xmp_ext_head, iptc_head] {
match find_bytes(data, from, to, head) {
Some(off) => raise ExtraMetadata(off)
None => ()
}
}
}
///|
/// 取出 APP1 里的 TIFF 块。文件没有 EXIF 是 `None`,不是错误。
pub fn jpeg_exif(data : Bytes) -> Bytes? raise MetaError {
match jpeg_inspect(data).exif {
None => None
Some((s, e)) => Some(slice_bytes(data, s, e))
}
}
///|
/// 读出 JPEG 里的 EXIF。没有 EXIF 时返回 `None`。
pub fn jpeg_decode(data : Bytes) -> TiffBlock? raise MetaError {
match jpeg_exif(data) {
None => None
Some(tiff) => Some(decode_tiff(tiff))
}
}
///|
/// 用新的 TIFF 块替换 APP1;原本没有 EXIF 就插在 SOI 之后。
/// 其余字节——XMP、注释、每一个与元数据无关的段——按原样搬过去。
pub fn jpeg_replace_exif(data : Bytes, tiff : Bytes) -> Bytes raise MetaError {
let map = jpeg_inspect(data)
let body = data.to_array()
let (from, to) = match map.exif {
Some((s, e)) => (s - app1_overhead, e)
None => {
let at = insert_point(map)
(at, at)
}
}
let seg = app1_segment(tiff, from)
let out : Array[Byte] = []
push_all(out, body, 0, from)
push_all(out, seg, 0, seg.length())
push_all(out, body, to, body.length())
Bytes::from_array(out)
}
///|
/// 原本没有 EXIF 时的插入点:APP0 之后,其次才紧跟 SOI。
///
/// JFIF 要求 APP0 是 SOI 之后的第一段,把 EXIF 插到它前面会让
/// 一部分读取器直接不认这个文件——写出去的东西得让别人打得开。
fn insert_point(map : JpegMap) -> Int {
match map.segments.get(1) {
Some(s) if s.marker == 0xe0 => s.offset + s.length
_ => 2
}
}
///|
/// 整段摘掉 APP1/EXIF,并报告"原本确实有一段"。
///
/// 那个 Bool 不是装饰:调用方要能区分"删掉了"和"本来就没有"。
/// 把"本来就没有"说成"已清除"的工具没人敢信。
pub fn jpeg_remove_exif(data : Bytes) -> (Bytes, Bool) raise MetaError {
let map = jpeg_inspect(data)
match map.exif {
None => (data, false)
Some((s, e)) => {
let body = data.to_array()
let out : Array[Byte] = []
push_all(out, body, 0, s - app1_overhead)
push_all(out, body, e, body.length())
(Bytes::from_array(out), true)
}
}
}
///|
/// 补集搬运:把这些段区间之外的字节原样搬过去。
///
/// 之所以是"搬"而不是"重建文件":压缩流、量化表、注释段一概不重新编码,
/// 所以像素不可能被动到。
fn drop_spans(data : Bytes, spans : Array[(Int, Int)]) -> Bytes {
let body = data.to_array()
let out : Array[Byte] = []
let mut cursor = 0
for (s, e) in spans {
push_all(out, body, cursor, s)
cursor = e
}
push_all(out, body, cursor, body.length())
Bytes::from_array(out)
}
///|
/// 整段摘掉 APP1/XMP,并报告"原本确实有一段"。
///
/// 为什么必须动这一段:XMP 里是同一批事实的第二份副本——真实语料里
/// `exif:DateTimeOriginal`、`tiff:Make/Model`、`dc:creator`、`aux:SerialNumber`
/// 都出现过。EXIF 删干净、XMP 原样留着,报告上一切正常,实际什么都没脱。
pub fn jpeg_drop_xmp(data : Bytes) -> (Bytes, Bool) raise MetaError {
let spans = jpeg_inspect(data).xmp
if spans.is_empty() {
(data, false)
} else {
(drop_spans(data, spans), true)
}
}
///|
/// 整段摘掉 APP13 的 Photoshop 资源包(IPTC-IIM 住在里面),并报告"原本确实有一段"。
///
/// 和 XMP 同一个道理,也同一条待遇:整包判断不了里面哪条敏不敏感,
/// 但"留着整包"一定比"整包丢掉"更糟——IFD0 里的 `0x83bb`/`0x02bd` 早就是整条丢的,
/// 只差 JPEG 这一份副本没说。不认它,`strip` 那句"摘干净"就是假的。
///
/// 只动带 `Photoshop 3.0\0` 头的那几段:APP13 里还有 Adobe 的色彩管理包,
/// 删它会把颜色改掉,而它一条隐私都不装。
pub fn jpeg_drop_iptc(data : Bytes) -> (Bytes, Bool) raise MetaError {
let spans = jpeg_inspect(data).iptc
if spans.is_empty() {
(data, false)
} else {
(drop_spans(data, spans), true)
}
}
///|
/// 解析 → 按策略删 → 回写,一步到位。没有 EXIF 的文件原样退回。
pub fn jpeg_redact(
data : Bytes,
p : Policy,
) -> (Bytes, Redaction) raise MetaError {
let map = jpeg_inspect(data)
let (out, report) = match map.exif {
None =>
(
data,
{
removed: [],
dropped_thumbnail: false,
dropped_xmp: false,
dropped_iptc: false,
},
)
Some((s, e)) => {
let block = decode_tiff(slice_bytes(data, s, e))
let (kept, report) = block.redact(p)
// 一条没删就不重写这个段:我们的编码器按自己的布局排偏移,
// 和相机原来的字节布局不保证相同(实测重编码会长 4 个字节)。
// 空策略必须是恒等,不能顺手把文件规范化一遍。
if report.removed.is_empty() && !report.dropped_thumbnail {
(data, report)
} else {
(jpeg_replace_exif(data, encode_tiff(kept)), report)
}
}
}
// 第二趟、第三趟都重新走查一遍,不沿用上一趟的偏移:EXIF 段的长度已经变了,
// 记在旧偏移上的 XMP 段位置这时候是错的;摘掉 XMP 之后 IPTC 的偏移又错了一次。
if !p.covers(Carrier) {
(out, report)
} else {
let (out2, dropped) = jpeg_drop_xmp(out)
let (out3, dropped_iptc) = jpeg_drop_iptc(out2)
(out3, { ..report, dropped_xmp: dropped, dropped_iptc, })
}
}
///|
/// 造一个 APP1/EXIF 段:`FF E1` + 长度 + 标识 + TIFF 块。
///
/// 段长字段只有 16 位,装不下就直接拒绝。写一个截断的长度域,
/// 等于交出一个别人打不开却说自己合法的文件。
fn app1_segment(tiff : Bytes, at : Int) -> Array[Byte] raise MetaError {
let total = 2 + exif_head.length() + tiff.length()
if total > 65535 {
raise SegmentOverflow(at, 65535, total)
}
let out : Array[Byte] = [b'\xff', b'\xe1']
out.push((total >> 8).to_byte())
out.push((total & 0xff).to_byte())
push_all(out, exif_head, 0, exif_head.length())
let body = tiff.to_array()
push_all(out, body, 0, body.length())
out
}
// ---- 字节小工具 ----
///|
fn push_all(out : Array[Byte], src : Array[Byte], from : Int, to : Int) -> Unit {
let mut i = from
while i < to {
out.push(src[i])
i = i + 1
}
}
///|
fn slice_bytes(data : Bytes, from : Int, to : Int) -> Bytes raise MetaError {
let out : Array[Byte] = []
let mut i = from
while i < to {
out.push(byte_at(data, i))
i = i + 1
}
Bytes::from_array(out)
}
///|
fn starts_with(data : Bytes, at : Int, head : Array[Byte]) -> Bool {
let n = head.length()
if at < 0 || at + n > data.length() {
false
} else {
let mut i = 0
let mut same = true
while i < n && same {
same = match data.get(at + i) {
Some(b) => b == head[i]
None => false
}
i = i + 1
}
same
}
}
///|
/// 在 `[from, to)` 里找子串。边界由调用方给:
/// "整份文件扫一遍"和"只扫这一段载荷"是两个不同的问题,答案差得很远。
fn find_bytes(data : Bytes, from : Int, to : Int, needle : Array[Byte]) -> Int? {
let n = to
let m = needle.length()
if m == 0 || from + m > n {
None
} else {
let mut p = from
while p + m <= n {
if starts_with(data, p, needle) {
return Some(p)
}
p = p + 1
}
None
}
}
///|
/// 段码的规范短名。认不出来就打编号,不猜。
pub fn marker_name(marker : Int) -> String {
if marker >= 0xe0 && marker <= 0xef {
"APP\{marker - 0xe0}"
} else if marker >= 0xd0 && marker <= 0xd7 {
"RST\{marker - 0xd0}"
} else {
match marker {
0x01 => "TEM"
0x08 => "JPG0"
0xc0 => "SOF0"
0xc1 => "SOF1"
0xc2 => "SOF2"
0xc3 => "SOF3"
0xc4 => "DHT"
0xc8 => "JPG"
0xcc => "DAC"
0xd8 => "SOI"
0xd9 => "EOI"
0xda => "SOS"
0xdb => "DQT"
0xdc => "DNL"
0xdd => "DRI"
0xfe => "COM"
_ => "0x\{marker.to_byte().to_hex()}"
}
}
}
///|
pub fn JpegSegment::name(self : JpegSegment) -> String {
marker_name(self.marker)
}
///|
/// 一行段表,给 CLI 的审计输出用。
pub fn JpegMap::describe(self : JpegMap) -> String {
let mut head = "JPEG"
for s in self.segments {
head = head + " \{s.name()}@\{s.offset}"
}
if self.has_xmp {
head = head + " ; 另含 XMP 包 \{self.xmp.length()} 段"
}
if self.has_iptc {
head = head + " ; 另含 IPTC/Photoshop 包 \{self.iptc.length()} 段"
}
if !self.unread.is_empty() {
let names = self.unread.map(s => "\{s.name()}@\{s.offset}").join(" ")
head + " ; 未解析段 \{self.unread.length()} 段:\{names}"
} else {
head
}
}