use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::path::Path;
use std::sync::LazyLock;
use anyhow::{Context, Result};
use regex::Regex;
use crate::finding::{Category, Finding, OutlineScan, Severity};
static FONTKEY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"w:fontKey="(\{[0-9A-Fa-f-]+\})""#).unwrap());
static WT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<w:t[ >](.*?)</w:t>").unwrap());
pub(crate) fn deobfuscate(odttf: &[u8], guid: &str, reversed: bool) -> Vec<u8> {
let hexs: String = guid.chars().filter(|c| c.is_ascii_hexdigit()).collect();
if hexs.len() != 32 {
return odttf.to_vec();
}
let mut key = [0u8; 16];
for i in 0..16 {
key[i] = u8::from_str_radix(&hexs[i * 2..i * 2 + 2], 16).unwrap_or(0);
}
let mut out = odttf.to_vec();
let n = out.len().min(32);
for i in 0..n {
let kb = if reversed { key[15 - (i % 16)] } else { key[i % 16] };
out[i] ^= kb;
}
out
}
use crate::font::glyph_outline_hash;
use crate::glyphmatch::{legitimately_identical, letter_latin as letter};
#[derive(Default)]
struct Tally {
by_letter: HashMap<char, usize>,
}
fn document_letters(zip: &mut zip::ZipArchive<std::fs::File>, names: &[String]) -> HashSet<char> {
let mut set = HashSet::new();
for name in names {
let textpart = name == "word/document.xml"
|| (name.starts_with("word/header") && name.ends_with(".xml"))
|| (name.starts_with("word/footer") && name.ends_with(".xml"))
|| name == "word/footnotes.xml"
|| name == "word/endnotes.xml";
if !textpart {
continue;
}
let mut s = String::new();
if zip.by_name(name).map(|mut zf| zf.read_to_string(&mut s)).is_err() {
continue;
}
for cap in WT_RE.captures_iter(&s) {
for c in cap[1].chars() {
if c.is_alphabetic() {
if let Some(l) = c.to_lowercase().next() {
set.insert(l);
}
}
}
}
}
set
}
pub fn docx_canonical_substitutions(path: &Path, registry: &crate::registry::FontRegistry) -> Result<Vec<(char, char)>> {
let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
let mut keys: Vec<String> = Vec::new();
if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
let mut s = String::new();
if zf.read_to_string(&mut s).is_ok() {
for cap in FONTKEY_RE.captures_iter(&s) {
keys.push(cap[1].to_string());
}
}
}
let odttf: Vec<String> =
names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();
let mut out = Vec::new();
for name in &odttf {
let mut raw = Vec::new();
if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
continue;
}
'keys: for guid in &keys {
for reversed in [true, false] {
let de = deobfuscate(&raw, guid, reversed);
let faces = crate::font::parse_data(&de);
if !faces.is_empty() {
for pf in &faces {
out.extend(registry.canonical_substitutions(pf));
}
break 'keys;
}
}
}
}
out.sort_unstable();
out.dedup();
Ok(out)
}
pub fn docx_outline_scan(path: &Path) -> Result<OutlineScan> {
let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
let doc_letters = document_letters(&mut zip, &names);
let mut keys: Vec<String> = Vec::new();
if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
let mut s = String::new();
if zf.read_to_string(&mut s).is_ok() {
for cap in FONTKEY_RE.captures_iter(&s) {
keys.push(cap[1].to_string());
}
}
}
let mut sigs: HashMap<u64, Tally> = HashMap::new();
let odttf: Vec<String> =
names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();
for name in &odttf {
let mut raw = Vec::new();
if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
continue;
}
let mut parsed = false;
'keys: for guid in &keys {
for reversed in [true, false] {
let de = deobfuscate(&raw, guid, reversed);
if let Ok(face) = ttf_parser::Face::parse(&de, 0) {
tally_face(&face, &doc_letters, &mut sigs);
parsed = true;
break 'keys;
}
}
}
let _ = parsed;
}
let mut lies: HashMap<(char, char), usize> = HashMap::new();
for tally in sigs.values() {
if tally.by_letter.len() < 2 {
continue;
}
let mut letters: Vec<(char, usize)> = tally.by_letter.iter().map(|(c, n)| (*c, *n)).collect();
letters.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
let (truth, _) = letters[0];
for (lie, n) in letters.iter().skip(1) {
if legitimately_identical(truth, *lie) {
continue;
}
*lies.entry((truth, *lie)).or_default() += *n;
}
}
let mut best: HashMap<char, (char, usize)> = HashMap::new();
for (&(truth, lie), &n) in &lies {
let e = best.entry(lie).or_insert((truth, 0));
if n > e.1 {
*e = (truth, n);
}
}
let mut substitutions: Vec<(char, char)> = best.into_iter().map(|(lie, (truth, _))| (lie, truth)).collect();
substitutions.sort_unstable();
let mut findings: Vec<Finding> = lies
.into_iter()
.map(|((truth, lie), n)| {
Finding::new(
"DOCX.GLYPH_SEMANTIC_REPLACEMENT",
Severity::High,
Category::FontIntegrity,
"embedded font",
format!(
"a glyph that draws '{truth}' is mapped from the extracted character '{lie}' ({n}×): the embedded font's cmap lies (semantic replacement / variant A3)"
),
0.9,
)
})
.collect();
findings.sort_by(|a, b| a.message.cmp(&b.message));
Ok(OutlineScan { findings, substitutions })
}
pub fn docx_font_claims(path: &Path) -> Result<Vec<crate::FontClaims>> {
use std::collections::HashSet;
let file = std::fs::File::open(path).with_context(|| format!("opening DOCX {}", path.display()))?;
let mut zip = zip::ZipArchive::new(file).context("DOCX is not a valid ZIP")?;
let names: Vec<String> = zip.file_names().map(|s| s.to_string()).collect();
let doc_letters = document_letters(&mut zip, &names);
let mut keys: Vec<String> = Vec::new();
if let Ok(mut zf) = zip.by_name("word/fontTable.xml") {
let mut s = String::new();
if zf.read_to_string(&mut s).is_ok() {
for cap in FONTKEY_RE.captures_iter(&s) {
keys.push(cap[1].to_string());
}
}
}
let odttf: Vec<String> =
names.iter().filter(|n| n.to_ascii_lowercase().ends_with(".odttf")).cloned().collect();
let mut out: Vec<crate::FontClaims> = Vec::new();
for name in &odttf {
let mut raw = Vec::new();
if zip.by_name(name).map(|mut zf| zf.read_to_end(&mut raw)).is_err() {
continue;
}
'keys: for guid in &keys {
for reversed in [true, false] {
let de = deobfuscate(&raw, guid, reversed);
let Ok(face) = ttf_parser::Face::parse(&de, 0) else { continue };
let Some(cmap) = face.tables().cmap else { break 'keys };
let mut claims: HashSet<(char, u16)> = HashSet::new();
for sub in cmap.subtables {
if !sub.is_unicode() {
continue;
}
sub.codepoints(|cp| {
let Some(l) = letter(cp) else { return };
if !doc_letters.contains(&l) {
return;
}
if let (Some(c), Some(gid)) = (char::from_u32(cp), sub.glyph_index(cp)) {
claims.insert((c, gid.0));
}
});
}
if !claims.is_empty() {
out.push((de, claims.into_iter().collect()));
}
break 'keys;
}
}
}
Ok(out)
}
fn tally_face(face: &ttf_parser::Face, doc_letters: &HashSet<char>, sigs: &mut HashMap<u64, Tally>) {
let Some(cmap) = face.tables().cmap else { return };
for sub in cmap.subtables {
if !sub.is_unicode() {
continue;
}
sub.codepoints(|cp| {
let Some(letter) = letter(cp) else { return };
if !doc_letters.contains(&letter) {
return;
}
if let Some(gid) = sub.glyph_index(cp) {
if let Some(h) = glyph_outline_hash(face, gid.0) {
*sigs.entry(h).or_default().by_letter.entry(letter).or_default() += 1;
}
}
});
}
}