// 原位改写:只删目录里的条目,一个字节都不搬。
//
// 重写路径(`moonmeta_encode.mbt`)把整份文件由 IR 重新产出,所以裸 TIFF
// 过不了 `check_tiff_rewrite`:IFD 之间和之后的像素数据、缩略图链、
// 我们模型装不下的任何字节都会丢。这条路反过来——**字节留在原地**,
// 只做单调收缩:
//
// * 被删条目的外置值区间逐字节清零;
// * 保留条目的 12 字节槽位往前搬,腾出来的表尾清零;
// * 每表开头的条目数(u16)改小,表末尾的 next 指针跟着搬到新表尾。
//
// 这三步的先后是承重的,见 `apply_plan`:一个条目的值指针可以指到本表自己的
// 槽位区(畸形输入就是这么造的),清零必须先落,否则它会落在刚搬好的条目上。
//
// next 指针那 4 字节必须跟着计数走:读侧算它的位置用的是 `off + 2 + 12n`,
// 光把 n 改小,它就落在清零的腾空表尾上,读出来是 0——缩略图链不报错,
// 只是安静地没了。这条缺陷是单测逼出来的,语料门禁当时看不见(它比的是
// "点名区间之外别多动",而 next 落在点名的表尾里)。旧位置那 4 字节原样
// 留着:计数改小之后它已在表外,任何读侧都到不了,动它反而多出一次写。
//
// 于是"写下去会丢字节"在构造上不可能发生:输出长度永远等于输入长度,
// 改动只落在报告点名的区间里。代价是两条必须出声的边界:缩略图链
// (IFD1 及其后继)只登记不修改,像素字节一个都不碰。
//
// 落笔之前先算一份"受保护区间":文件头、每一张只登记的目录表及其
// 所有值区间、保留条目的值区间与槽位、表末尾的 next 指针、以及像素数据
// (由 StripOffsets/StripByteCounts、TileOffsets/TileByteCounts 配对得出)。
// 要写的地方与要清零的地方都不能和它们相交——相交就拒绝,而不是猜。
// 104 份陌生 Pillow TIFF 两条腿各跑一遍(2026-09-25),守卫这一族(Shared)
// 一份都没逼出来——它挡的是形状上的可能,两格单测用手工夹具各造了一遍"值
// 压在像素上"和"两个条目共用一段字节"。语料真逼出来的是另外三种:目录成环
// 2 份、像素声明读不懂 strict 腿 6 份 / strip 腿 26 份、类型码这一版不解码 1 份。
///|
/// 一张走过的目录表。
priv struct PTable {
off : Int
entries : Array[IfdEntry]
/// 与 `entries` 一一对应。内联值(<=4 字节)的区间落在自己的槽位里。
spans : Array[(Int, Int)]
/// 表末尾 next 指针的值,0 = 没有后继。
next : Int64
}
///|
/// 本库负责改写的那几张表,带着它的目录身份。
priv struct Mine {
dir : Dir
table : PTable
}
///|
/// 一个文件的目录图:可改写的四张 + 只登记不碰的后继链。
priv struct Patch {
little : Bool
mine : Array[Mine]
/// IFD0 的后继链(缩略图目录)与每张表的后继。只登记为受保护区间。
foreign : Array[PTable]
}
///|
/// 一张改写表的活儿。
priv struct Job {
/// 表头偏移。
off : Int
/// 原表的条目数:搬完之后 [新终点, 旧终点) 要清零。
old : Int
/// 保留条目的下标,按原顺序。
keep : Array[Int]
}
///|
priv enum GuardKind {
Header
ForeignTable
ForeignValue
KeptValue
KeptSlot
Pixel
} derive(Eq)
///|
/// 一段不许被碰的字节。`owner` 是它所在目录表的偏移,判豁免时用它认出
/// "同一张表自己那些已经搬走的槽位"。
priv struct Guard {
from : Int
to : Int
owner : Int
kind : GuardKind
}
///|
/// 一次原位改写的施工单。
priv struct Plan {
removed : Array[Removed]
jobs : Array[Job]
/// 要逐字节清零的外置值区间。
scrub : Array[(Int, Int)]
little : Bool
/// 真的被删掉的条目数。不能由 `removed` 的长度代替:`strip` 全删,
/// 但报告只点名有敏感类别可归的那几条,剩下的删了也没名字可报。
dropped : Int
/// IFD0 里有没有 XMP 载体条目(0x02bc)。全删时它必然跟着没。
carrier : Bool
}
///|
/// 删法:按策略删,还是像 `strip` 那样全删。
priv enum Drop {
ByPolicy(Policy)
All
}
///|
/// 按策略原位清目录,交出与重写路径同一份报告。
///
/// `removed` 必须与 `TiffBlock::findings` 逐条相同:两条路共用
/// `sensitive_category`,却各走各的字节,这个一致性由测试钉住。
///
/// `dropped_thumbnail` 在这里永远是 `false`:原位模式不丢缩略图链,
/// 这正是它存在的理由,也是它的边界——缩略图里可能自带坐标,
/// 要连它一起清就走不带 `--keep-bytes` 的那条路。
pub fn tiff_redact_inplace(
data : Bytes,
p : Policy,
) -> (Bytes, Redaction) raise MetaError {
let plan = make_plan(data, ByPolicy(p))
if plan.dropped == 0 {
(
data,
{
removed: [],
dropped_thumbnail: false,
dropped_xmp: false,
dropped_iptc: false,
},
)
} else {
// dropped_iptc 在这里恒为 false:裸 TIFF 的 IPTC 是 IFD0 里的一条(0x83bb),
// 它已经在 `removed` 清单里逐条交代过了。那个字段说的是 JPEG 的 APP13
// 那一种成包副本——条目清单天然装不下的那种。
(
apply_plan(data, plan),
{
removed: plan.removed,
dropped_thumbnail: false,
dropped_xmp: false,
dropped_iptc: false,
},
)
}
}
///|
/// 原位整段摘除:归本库管的四张表清空,像素与缩略图链原样留在文件里。
///
/// 与 `tiff_redact_inplace` 的分工沿用 `strip` 一贯的承诺:摘完不再声明
/// 任何 EXIF 结构(表还在,条目数为 0)。
pub fn tiff_strip_inplace(data : Bytes) -> (Bytes, Strip) raise MetaError {
let plan = make_plan(data, All)
if plan.dropped == 0 {
(data, { had_exif: false, dropped_xmp: false, dropped_iptc: false, })
} else {
// 与 `strip_any` 的裸 TIFF 分支同源:载体条目(XMP / Photoshop /
// IPTC-NAA)就在 IFD0 里,看它在不在,而不是看它归哪一类。
(
apply_plan(data, plan),
{ had_exif: true, dropped_xmp: plan.carrier, dropped_iptc: false, },
)
}
}
///|
/// IFD0 里有没有 XMP 载体条目。
fn has_carrier(entries : Array[IfdEntry]) -> Bool {
find_entry(entries, 0x02bc) != None
}
///|
/// 读目录图 + 定删法 + 算守卫 + 逐区间判冲突。
fn make_plan(data : Bytes, drop : Drop) -> Plan raise MetaError {
let patch = scan(data)
let jobs : Array[Job] = []
let removed : Array[Removed] = []
// 被删条目的值区间,等守卫算出来以后再判冲突。
let doomed : Array[(Int, Int, Int)] = []
let mut dropped = 0
let mut carrier = false
for m in patch.mine {
let t = m.table
if m.dir == Ifd0 && has_carrier(t.entries) {
carrier = true
}
let keep : Array[Int] = []
for i = 0; i < t.entries.length(); i = i + 1 {
let e = t.entries[i]
if drops(m.dir, e.tag, drop) {
dropped = dropped + 1
match removed_record(m.dir, e.tag) {
Some(r) => removed.push(r)
None => ()
}
let (from, to) = t.spans[i]
doomed.push((from, to, e.tag))
} else {
keep.push(i)
}
}
jobs.push({ off: t.off, old: t.entries.length(), keep, })
}
if dropped == 0 {
// 无事可做就别去审判像素声明:这一条路在这里一个字节都不写(两个入口
// 都在 `dropped == 0` 时原样交回输入),而"读不懂像素在哪"这种风险,
// 只在真要落笔时才成立。
return {
removed,
jobs: [],
scrub: [],
little: patch.little,
dropped,
carrier,
}
}
let guards = guards_of(data, patch, jobs)
let scrub : Array[(Int, Int)] = []
for (from, to, tag) in doomed {
// 内联值跟着槽位一起消失;外置值要单独清零,那一刀必须无人认领。
if to > from + 4 {
check_free(from, to, tag, guards, -1)
scrub.push((from, to))
}
}
// 施工区:新条目区(含改小的条目数)与搬空后清零的表尾。
for j in jobs {
let dest_to = j.off + 2 + 12 * j.keep.length()
check_free(j.off, dest_to, 0, guards, j.off)
check_free(dest_to, j.off + 2 + 12 * j.old, 0, guards, j.off)
}
{ removed, jobs, scrub, little: patch.little, dropped, carrier, }
}
///|
/// 这个条目要不要删。
///
/// 子目录指针是结构不是数据:删掉它等于让整张子表失联,两种删法都不动它。
///
/// 全删法还有一条更硬的边界:裸 TIFF 里"这张图怎么解出来"和元数据住在
/// 同一张 IFD0 表里。把 256/259/262/273/278 一起删掉,产物就不再是一张图
/// ——门禁在 104 份陌生 TIFF 里量到 81 份因此解不出像素。所以这条路做不到
/// `strip` 在 JPEG / PNG 上那句"整段摘除",它的上限是**删掉所有不参与解码
/// 的条目**,见 `image_shape`。
fn drops(dir : Dir, tag : Int, drop : Drop) -> Bool {
if tag == tag_exif_ifd || tag == tag_gps_ifd || tag == tag_interop_ifd {
false
} else {
match drop {
All => !(dir == Ifd0 && is_image_shape(tag))
ByPolicy(p) =>
match sensitive_category(dir, tag) {
Some(cat) => p.covers(cat)
None => false
}
}
}
}
///|
/// 解一张裸 TIFF 的像素绕不开的条目(TIFF 6.0 的图像结构字段:尺寸、位深、
/// 压缩、色彩解释、条带/瓦片的偏移与长度,以及解码参数)。
///
/// 274 Orientation 也在这一份里,是语料把它逼进来的:删掉它之后 Pillow 不再
/// 翻转,产物的解码像素正好是输入的水平镜像(`g4_orientation_2.tif`)——
/// "这条目只管显示方向"是错的,删了图就变。反过来,282/283/296 那几个分辨率
/// 条目不在清单里:门禁逐份比过像素哈希,删掉它们一个像素都不动。
///
/// 这里头没有任何一条说的是"谁拍的、什么时候、在哪儿":GeoTIFF 那几个
/// 带位置的字段(33550/33922/34735…)故意不在清单里。
///
/// 346/347 也是同一批语料逼进来的:JPEG 压缩的 TIFF 把公共量化表放在 347
/// (JPEGTables),条带里只存差分表,删掉它 Pillow 直接报 `decoder error -2`
/// (4 份),346 是同一家族的变换提示——按 274 那次的教训,不再猜"它只是显示用"。
let image_shape : Array[Int] = [
256, 257, 258, 259, 262, 266, 273, 274, 277, 278, 279, 284, 317, 320, 322, 323,
324, 325, 326, 338, 339, 340, 341, 346, 347, 530, 531, 532,
]
///|
fn is_image_shape(tag : Int) -> Bool {
image_shape.contains(tag)
}
///|
fn removed_record(dir : Dir, tag : Int) -> Removed? {
sensitive_category(dir, tag).map(cat => {
dir,
tag,
name: tag_name(dir, tag),
category: cat,
})
}
///|
/// 走一遍这个文件的目录图。
///
/// 目录发现顺序与 `decode_tiff` 一致(GPS 指针先 IFD0 后 Exif,Interop 在
/// Exif 里),两条路才不会看见两张不同的图。
fn scan(data : Bytes) -> Patch raise MetaError {
if data.length() < 8 {
raise TooShort(0, 8, data.length())
}
let b0 = byte_at(data, 0)
let b1 = byte_at(data, 1)
let little = if b0 == b'I' && b1 == b'I' {
true
} else if b0 == b'M' && b1 == b'M' {
false
} else {
raise BadByteOrder(0)
}
if u16_at(data, 2, little) != 42 {
raise BadTiffHeader(2)
}
let seen : Array[Int] = []
let mine : Array[Mine] = []
let foreign : Array[PTable] = []
let ifd0 = read_table(
data,
check_ifd_offset(data, u32_at(data, 4, little)),
little,
seen,
)
mine.push({ dir: Ifd0, table: ifd0, })
walk_chain(data, ifd0.next, little, seen, foreign)
let exif = match sub_dir_offset(data, ifd0.entries, tag_exif_ifd) {
Some(off) => Some(read_table(data, off, little, seen))
None => None
}
match exif {
Some(t) => {
mine.push({ dir: Exif, table: t, })
walk_chain(data, t.next, little, seen, foreign)
}
None => ()
}
let gps_off = match sub_dir_offset(data, ifd0.entries, tag_gps_ifd) {
Some(off) => Some(off)
None =>
match exif {
Some(t) => sub_dir_offset(data, t.entries, tag_gps_ifd)
None => None
}
}
match gps_off {
Some(off) => adopt(data, off, Gps, little, seen, mine, foreign)
None => ()
}
let interop_off = match exif {
Some(t) => sub_dir_offset(data, t.entries, tag_interop_ifd)
None => None
}
match interop_off {
Some(off) => adopt(data, off, Interop, little, seen, mine, foreign)
None => ()
}
{ little, mine, foreign, }
}
///|
/// 读一张归本库管的子表,登记它、再登记它后面那条只读链。
fn adopt(
data : Bytes,
off : Int,
dir : Dir,
little : Bool,
seen : Array[Int],
mine : Array[Mine],
foreign : Array[PTable],
) -> Unit raise MetaError {
let t = read_table(data, off, little, seen)
mine.push({ dir, table: t, })
walk_chain(data, t.next, little, seen, foreign)
}
///|
/// 沿 next 指针走链。这些表一律只登记、不改写。
fn walk_chain(
data : Bytes,
start : Int64,
little : Bool,
seen : Array[Int],
out : Array[PTable],
) -> Unit raise MetaError {
let mut next = start
while next > 0L && next + 2L <= data.length().to_int64() {
let t = read_table(data, next.to_int(), little, seen)
out.push(t)
next = t.next
}
}
///|
fn read_table(
data : Bytes,
off : Int,
little : Bool,
seen : Array[Int],
) -> PTable raise MetaError {
for v in seen {
if v == off {
// 两张表叠在一起:谁改写都对,但没人说得清哪一份是真的。
raise TiffInplaceCycle(off)
}
}
seen.push(off)
let raw = parse_dir(data, off, little)
{
off,
entries: raw.entries,
spans: entry_spans(data, off, raw.entries, little),
next: raw.next,
}
}
///|
/// 逐条算值的字节区间。判内联/外置与 `parse_entry` 同源:
/// 分量数 × 单字节数 <= 4 就是内联,落在槽位偏移 +8 处。
fn entry_spans(
data : Bytes,
off : Int,
entries : Array[IfdEntry],
little : Bool,
) -> Array[(Int, Int)] raise MetaError {
let out : Array[(Int, Int)] = []
for i = 0; i < entries.length(); i = i + 1 {
let e = entries[i]
let size = match type_size(e.type_code) {
Some(s) => s
None => raise UnsupportedType(e.tag, e.type_code)
}
let total = value_len(e.value) * size
let slot = off + 2 + 12 * i
if total <= 4 {
out.push((slot + 8, slot + 12))
} else {
let voff = u32_at(data, slot + 8, little).to_int()
out.push((voff, voff + total))
}
}
out
}
///|
/// 一个值有多少个分量。`decode_value` 按声明的 count 读,所以分量数
/// 就是 count;ASCII 读的是逐字节(含结尾 NUL),也等于 count。
fn value_len(v : TagValue) -> Int {
match v {
Text(s) => s.length()
Raw(bs) => bs.length()
Shorts(a) => a.length()
Longs(a) => a.length()
Rationals(a) => a.length()
}
}
///|
/// 谁不许被碰。清单见文件顶上那段。
fn guards_of(
data : Bytes,
patch : Patch,
jobs : Array[Job],
) -> Array[Guard] raise MetaError {
let out : Array[Guard] = [{ from: 0, to: 8, owner: 0, kind: Header, }]
for t in patch.foreign {
let body = t.off + 2 + 12 * t.entries.length()
out.push({ from: t.off, to: body + 4, owner: t.off, kind: ForeignTable, })
for s in t.spans {
let (from, to) = s
if to > from + 4 {
out.push({ from, to, owner: t.off, kind: ForeignValue, })
}
}
}
for m in patch.mine {
let t = m.table
// 改写表的槽位区是施工场地;表末尾那 4 字节 next 指针不是。
let body = t.off + 2 + 12 * t.entries.length()
out.push({ from: body, to: body + 4, owner: t.off, kind: ForeignTable, })
}
for j in jobs {
let spans = spans_of(patch, j.off)
for i in j.keep {
let slot = j.off + 2 + 12 * i
out.push({ from: slot, to: slot + 12, owner: j.off, kind: KeptSlot, })
let (from, to) = spans[i]
if to > from + 4 {
out.push({ from, to, owner: j.off, kind: KeptValue, })
}
}
}
for g in pixel_guards_of(data, patch) {
out.push(g)
}
out
}
///|
fn spans_of(patch : Patch, off : Int) -> Array[(Int, Int)] {
for m in patch.mine {
if m.table.off == off {
return m.table.spans
}
}
[]
}
///|
/// 像素数据声明在哪儿,哪儿就不许碰。
///
/// 认三对标准 tag:条带(273/278)、瓦片(324/325)、缩略图(0x0201/0x0202)。
/// 声明看不懂就报拒绝,而不是"那就清零试试":不知道像素在哪的原位改写,
/// 比不脱敏更糟。只有半边也算看不懂——单独一个数字无从解释。
///
/// "看不懂"有四种,各说各的句子,不许共用:缺半边、伙伴条数不等、
/// 声明的区间越出文件末尾、值本身不是偏移表。把前两种混成一句
/// 就是在替一个没发生的状况编理由(伙伴明明在,只是两边数不出一样多的数)。
fn pixel_guards_of(data : Bytes, patch : Patch) -> Array[Guard] raise MetaError {
let out : Array[Guard] = []
let all : Array[PTable] = []
for t in patch.foreign {
all.push(t)
}
for m in patch.mine {
all.push(m.table)
}
let pairs : Array[(Int, Int)] = [
(tag_strip_offsets, tag_strip_counts),
(tag_tile_offsets, tag_tile_counts),
(tag_thumb_offset, tag_thumb_length),
]
for t in all {
for (o_tag, c_tag) in pairs {
let offs = find_entry(t.entries, o_tag)
let cnts = find_entry(t.entries, c_tag)
match (offs, cnts) {
(Some(o), Some(c)) => guard_pixel(out, data, t.off, o, c)
(Some(o), None) => raise TiffInplaceOpaque(o.tag, 0)
(None, Some(c)) => raise TiffInplaceOpaque(c.tag, 0)
(None, None) => ()
}
}
}
out
}
///|
fn guard_pixel(
out : Array[Guard],
data : Bytes,
owner : Int,
offs : IfdEntry,
cnts : IfdEntry,
) -> Unit raise MetaError {
let from_list = number_values(offs)
let len_list = number_values(cnts)
if from_list.length() != len_list.length() {
raise TiffInplaceOpaque(offs.tag, -1)
}
for i = 0; i < from_list.length(); i = i + 1 {
let from = from_list[i]
let len = len_list[i]
if from < 0 || len < 0 || from + len > data.length() {
raise TiffInplaceOpaque(offs.tag, -2)
}
if len > 0 {
out.push({ from, to: from + len, owner, kind: Pixel, })
}
}
}
///|
let tag_strip_offsets : Int = 273
///|
/// StripByteCounts 是 278。279 是 SamplesPerPixel——它和 273 一样是个数组,
/// 配错了不会报错,只会把"像素在哪"读成一小段莫名其妙的区间。
let tag_strip_counts : Int = 278
///|
let tag_tile_offsets : Int = 324
///|
let tag_tile_counts : Int = 325
///|
fn number_values(e : IfdEntry) -> Array[Int] raise MetaError {
match e.value {
Shorts(a) => a
Longs(a) => a.map(x => x.to_int())
Text(_) | Raw(_) | Rationals(_) =>
raise TiffInplaceOpaque(e.tag, e.type_code)
}
}
///|
/// [from, to) 这段要写的地方有没有被认领。
///
/// `self_off` >= 0 时豁免"这张表自己的保留槽位":那些字节是先读后盖的源,
/// 搬完就该被覆盖。被删条目的外置值清零传 -1,一分豁免都不给。
fn check_free(
from : Int,
to : Int,
tag : Int,
guards : Array[Guard],
self_off : Int,
) -> Unit raise MetaError {
if from >= to {
return ()
}
for g in guards {
let exempt = self_off >= 0 && g.kind == KeptSlot && g.owner == self_off
if from < g.to && g.from < to && !exempt {
raise TiffInplaceShared(tag, from, to - from, g.owner, region_desc(g))
}
}
}
///|
/// 这段被保护的字节在报告里叫什么。写这句是要用户能决定下一步:
/// 撞的是像素,就该走重写那条路;撞的是另一张目录表,就是文件本身有歧义。
fn region_desc(g : Guard) -> String {
match g.kind {
Header => "the file's own TIFF header"
ForeignTable => "a directory this mode only reads"
ForeignValue =>
"a value of a directory this mode only reads, at offset \{g.from}"
KeptValue => "a value this run keeps, at offset \{g.from}"
KeptSlot => "an entry slot this run keeps, at offset \{g.from}"
Pixel => "the image data itself, declared at offset \{g.from}"
}
}
///|
/// 落笔。顺序要紧:**先清被删条目的外置值,再搬槽位**。
///
/// 反过来越界过一次,是畸形输入逼出来的:一个条目的值指针可以指向**本表
/// 自己的槽位区**(把某个 ASCII 条目的偏移字段改小几位就得到),而受保护
/// 清单里本来就没有"本表要写的那一段"——那是施工场地,不是别人的字节。
/// 先搬再清,那一刀清零就落在刚写好的保留条目上,产物当场读不回来。
///
/// 先清之所以安全:施工场地 [off, 旧表尾) 的每一格都会在下一步被重新赋值
/// ——条目数被 poke、保留槽位从**原始的 `data`** 逐字节抄过来(不是从 `buf`,
/// 所以抄到的永远是动笔之前的值)、next 那 4 字节同样从 `data` 取、剩下的
/// 表尾一律清零。既然整段都会被覆写,落在其中的清零就是白做,而不做坏事。
fn apply_plan(data : Bytes, plan : Plan) -> Bytes raise MetaError {
let buf = data.to_array()
for s in plan.scrub {
let (from, to) = s
for p = from; p < to; p = p + 1 {
buf[p] = b'\x00'
}
}
for j in plan.jobs {
let mut w = j.off + 2
for i in j.keep {
let src = j.off + 2 + 12 * i
for k = 0; k < 12; k = k + 1 {
buf[w + k] = byte_at(data, src + k)
}
w = w + 12
}
poke_u16(buf, j.off, j.keep.length(), plan.little)
// next 指针跟着计数走:读侧按 `off + 2 + 12n` 找它,所以搬完槽位要把
// 那 4 字节重落到新表尾。源一律从 `data` 读——`buf` 里那个位置可能刚
// 被我们当成某个保留槽位的源用过,值还是老值,但别指望它。
let old_body = j.off + 2 + 12 * j.old
for k = 0; k < 4; k = k + 1 {
buf[w + k] = byte_at(data, old_body + k)
}
for p = w + 4; p < old_body; p = p + 1 {
buf[p] = b'\x00'
}
}
Bytes::from_array(buf)
}
///|
/// 在指定偏移写一个 u16。`moonmeta_encode.mbt` 的 `put_u16` 是往尾部追加,
/// 原位模式要的是"就在这一格"。
fn poke_u16(buf : Array[Byte], off : Int, v : Int, little : Bool) -> Unit {
let lo = (v & 0xff).to_byte()
let hi = ((v >> 8) & 0xff).to_byte()
if little {
buf[off] = lo
buf[off + 1] = hi
} else {
buf[off] = hi
buf[off + 1] = lo
}
}