@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTnef, parseTnef } from "@/lib/tnef";
|
||||
|
||||
/**
|
||||
* The blobs below are **built from the format description, not captured from
|
||||
* Outlook**. That is worth saying plainly: they prove the parser reads what the
|
||||
* spec says, and they cannot prove it reads what Outlook actually emits. The
|
||||
* cases most likely to differ in the wild are the MAPI property stream, where
|
||||
* real messages carry many more properties than these do, and named properties
|
||||
* (id >= 0x8000), which the parser stops at rather than guessing past.
|
||||
*/
|
||||
|
||||
const SIGNATURE = 0x223e9f78;
|
||||
|
||||
const ATT = {
|
||||
attachRenddata: 0x00069002,
|
||||
attachTitle: 0x00018010,
|
||||
attachData: 0x0006800f,
|
||||
attachment: 0x00069005,
|
||||
tnefVersion: 0x00089006,
|
||||
} as const;
|
||||
|
||||
const sum16 = (b: number[]) => b.reduce((a, x) => (a + x) & 0xffff, 0);
|
||||
const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff];
|
||||
const u32 = (v: number) => [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff];
|
||||
const ascii = (s: string) => [...s].map((c) => c.charCodeAt(0));
|
||||
const utf16 = (s: string) => [...s].flatMap((c) => u16(c.charCodeAt(0)));
|
||||
|
||||
interface Attr {
|
||||
level?: number;
|
||||
id: number;
|
||||
data: number[];
|
||||
/** Deliberately wrong, for the desync case. */
|
||||
badChecksum?: boolean;
|
||||
}
|
||||
|
||||
function tnef(attrs: Attr[], opts: { signature?: number } = {}): Uint8Array {
|
||||
const out: number[] = [...u32(opts.signature ?? SIGNATURE), ...u16(0x1234)];
|
||||
for (const a of attrs) {
|
||||
out.push(a.level ?? 2, ...u32(a.id), ...u32(a.data.length), ...a.data, ...u16(a.badChecksum ? (sum16(a.data) + 1) & 0xffff : sum16(a.data)));
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
/** A MAPI property stream carrying the given string properties. */
|
||||
function mapi(props: Array<{ id: number; type: number; value: string }>): number[] {
|
||||
const out: number[] = [...u32(props.length)];
|
||||
for (const p of props) {
|
||||
out.push(...u32(((p.id & 0xffff) << 16) | (p.type & 0xffff)));
|
||||
const bytes = p.type === 0x001f ? [...utf16(p.value), 0, 0] : [...ascii(p.value), 0];
|
||||
out.push(...u32(bytes.length), ...bytes);
|
||||
const pad = (4 - (bytes.length % 4)) % 4;
|
||||
for (let i = 0; i < pad; i++) out.push(0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [
|
||||
{ id: ATT.attachRenddata, data: new Array(14).fill(0) },
|
||||
{ id: ATT.attachTitle, data: [...ascii(name), 0] },
|
||||
...extra,
|
||||
{ id: ATT.attachData, data: ascii(body) },
|
||||
];
|
||||
|
||||
const text = (a: Uint8Array) => new TextDecoder().decode(a);
|
||||
|
||||
describe("isTnef", () => {
|
||||
it("recognises the types and the filename", () => {
|
||||
expect(isTnef("application/ms-tnef", null)).toBe(true);
|
||||
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
|
||||
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
|
||||
expect(isTnef("application/octet-stream", "WINMAIL.DAT")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves everything else alone", () => {
|
||||
expect(isTnef("application/pdf", "report.pdf")).toBe(false);
|
||||
expect(isTnef(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTnef", () => {
|
||||
it("pulls one attachment out, with its name and bytes", () => {
|
||||
const out = parseTnef(tnef(file("report.pdf", "hello")));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.name).toBe("report.pdf");
|
||||
expect(text(out[0]!.data)).toBe("hello");
|
||||
expect(out[0]!.size).toBe(5);
|
||||
// Guessed from the extension, since this blob names no type of its own.
|
||||
expect(out[0]!.type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("pulls several out, in order", () => {
|
||||
const out = parseTnef(tnef([...file("a.txt", "one"), ...file("b.png", "two"), ...file("c.zip", "three")]));
|
||||
expect(out.map((a) => a.name)).toEqual(["a.txt", "b.png", "c.zip"]);
|
||||
expect(out.map((a) => text(a.data))).toEqual(["one", "two", "three"]);
|
||||
expect(out.map((a) => a.type)).toEqual(["text/plain", "image/png", "application/zip"]);
|
||||
});
|
||||
|
||||
it("prefers the long filename over the 8.3 one", () => {
|
||||
// The whole reason for reading the MAPI stream at all.
|
||||
const attrs = file("QUARTE~1.DOC", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001e, value: "Quarterly Report Final.docx" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.name).toBe("Quarterly Report Final.docx");
|
||||
});
|
||||
|
||||
it("reads a unicode long filename", () => {
|
||||
const attrs = file("SHORT~1.DOC", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "四半期報告.docx" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.name).toBe("四半期報告.docx");
|
||||
});
|
||||
|
||||
it("takes the MIME type the blob states over one guessed from the name", () => {
|
||||
const attrs = file("data.bin", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x370e, type: 0x001e, value: "image/webp" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.type).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("falls back to octet-stream for a name that says nothing", () => {
|
||||
expect(parseTnef(tnef(file("mystery", "x")))[0]!.type).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("names an attachment that carries no title at all", () => {
|
||||
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachData, data: ascii("x") }]));
|
||||
expect(out[0]!.name).toBe("attachment");
|
||||
});
|
||||
|
||||
it("ignores attributes it has no use for", () => {
|
||||
const out = parseTnef(tnef([{ level: 1, id: ATT.tnefVersion, data: u32(0x00010000) }, ...file("a.txt", "one")]));
|
||||
expect(out.map((a) => a.name)).toEqual(["a.txt"]);
|
||||
});
|
||||
|
||||
it("is not TNEF, and says so quietly", () => {
|
||||
// The caller gets here by guessing from a filename, so this is an ordinary
|
||||
// answer rather than an error worth showing anybody.
|
||||
expect(parseTnef(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual([]);
|
||||
expect(parseTnef(tnef(file("a.txt", "x"), { signature: 0xdeadbeef }))).toEqual([]);
|
||||
expect(parseTnef(new Uint8Array([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps what it read when the stream goes out of step", () => {
|
||||
// Half the attachments beats none: the alternative is a reader who can see
|
||||
// the file is there and cannot have it.
|
||||
const bad = tnef([...file("good.txt", "kept"), { id: ATT.attachRenddata, data: new Array(14).fill(0), badChecksum: true }, ...file("lost.txt", "gone")]);
|
||||
const out = parseTnef(bad);
|
||||
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
|
||||
});
|
||||
|
||||
it("keeps what it read when the blob is truncated mid-attribute", () => {
|
||||
const full = tnef([...file("good.txt", "kept"), ...file("cut.txt", "partial")]);
|
||||
const out = parseTnef(full.slice(0, full.length - 12));
|
||||
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
|
||||
});
|
||||
|
||||
it("stops at a named property rather than guessing past it", () => {
|
||||
// A named property carries a GUID before its value; the stream cannot be
|
||||
// trusted to stay aligned past one, so the long name is simply not found.
|
||||
const attrs = file("SHORT~1.DOC", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x8001, type: 0x001e, value: "whatever" }, { id: 0x3707, type: 0x001e, value: "Long Name.docx" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.name).toBe("SHORT~1.DOC");
|
||||
});
|
||||
|
||||
it("survives a MAPI stream that is nonsense, keeping the attachment", () => {
|
||||
const attrs = file("keep.txt", "body", [{ id: ATT.attachment, data: [...u32(0xffff), 1, 2, 3] }]);
|
||||
const out = parseTnef(tnef(attrs));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.name).toBe("keep.txt");
|
||||
});
|
||||
|
||||
it("drops an attachment that has a name but no data", () => {
|
||||
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachTitle, data: [...ascii("empty.txt"), 0] }]));
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user