diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 9d7c2ad1298e1..88a75195d2b8c 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -85,13 +85,20 @@ import type { OfficeWorkerResult } from "./officePreviewProtocol"; describe("bounded binary previews", () => { it("preserves length-delimited local and global PAX attributes", async () => { const encode = (value: string) => new TextEncoder().encode(value); - const block = (name: string, content: Uint8Array, type = "0", size = content.length) => { + const block = ( + name: string, + content: Uint8Array, + type = "0", + size = content.length, + linkname = "", + ) => { const bytes = new Uint8Array(512 + Math.ceil(content.length / 512) * 512); const write = (offset: number, value: string) => bytes.set(encode(value), offset); write(0, name); write(124, size.toString(8).padStart(11, "0") + "\0"); bytes.fill(32, 148, 156); write(156, type); + write(157, linkname); write( 148, bytes @@ -123,6 +130,15 @@ describe("bounded binary previews", () => { block("Local", attributes({ size: "3", mtime: "-172800" }), "x"), block("short.txt", encode("bye"), "0", 0), block("again.txt", encode("again"), "0", 0), + block("Global", attributes({ size: "", mtime: "" }), "g"), + block("original.txt", encode("hello world")), + block("alias.txt", encode(""), "1", 0, "original.txt"), + block("chain.txt", encode(""), "1", 0, "alias.txt"), + block("Local", attributes({ linkpath: "original.txt" }), "x"), + block("pax-link.txt", encode(""), "1", 0, "wrong-target"), + block("LongLink", encode("original.txt\0"), "K"), + block("gnu-link.txt", encode(""), "1", 0, "wrong-target"), + block("original.txt", encode("replacement")), new Uint8Array(1024), ]; const bytes = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.length, 0)); @@ -154,6 +170,14 @@ describe("bounded binary previews", () => { ["world.txt", 5, "1969-12-31T00:00:00.000Z"], ["short.txt", 3, "1969-12-30T00:00:00.000Z"], ["again.txt", 5, "1969-12-31T00:00:00.000Z"], + ...[ + "original.txt", + "alias.txt", + "chain.txt", + "pax-link.txt", + "gnu-link.txt", + "original.txt", + ].map((path) => [path, 11, "1970-01-01T00:00:00.000Z"]), ]); const entry = await request({ type: "entry", path: "café\n9 size=1\nname.txt" }); if (entry.type !== "entry") throw new Error("Expected entry"); @@ -164,6 +188,42 @@ describe("bounded binary previews", () => { if (reopened.type !== "entry") throw new Error("Expected entry"); expect(new TextDecoder().decode(reopened.bytes)).toBe("hello"); expect(reopened.bytes.buffer.byteLength).toBe(5); + for (const path of ["alias.txt", "chain.txt", "pax-link.txt", "gnu-link.txt"]) { + const linked = await request({ type: "entry", path }); + if (linked.type !== "entry") throw new Error("Expected hard link entry"); + expect(new TextDecoder().decode(linked.bytes)).toBe("hello world"); + } + const unresolved = new Uint8Array( + await new Blob([ + block("missing.txt", encode(""), "1", 0, "nowhere.txt"), + block("early.txt", encode(""), "1", 0, "later.txt"), + block("later.txt", encode("hello")), + block("folder/", encode(""), "5"), + block("to-folder.txt", encode(""), "1", 0, "folder/"), + block("replaced.txt", encode("stale")), + block("replaced.txt", encode(""), "2", 0, "elsewhere.txt"), + block("to-replaced.txt", encode(""), "1", 0, "replaced.txt"), + block("Local", attributes({ linkpath: "" }), "x"), + block("emptied.txt", encode(""), "1", 0, "later.txt"), + new Uint8Array(1024), + ]).arrayBuffer(), + ); + const skipped = await request({ + type: "open", + kind: "archive", + name: "links.tar", + bytes: unresolved.buffer, + }); + if (skipped.type !== "archive") throw new Error("Expected archive"); + expect(skipped.entries.map((entry) => entry.path)).toEqual([ + "later.txt", + "folder/", + "replaced.txt", + ]); + expect(await request({ type: "entry", path: "replaced.txt" })).toEqual({ + type: "error", + message: "This archive entry is unavailable.", + }); } finally { worker.terminate(); } diff --git a/apps/web/src/components/files/binaryPreview.worker.ts b/apps/web/src/components/files/binaryPreview.worker.ts index c0f3c2a17410f..124a1261e39e3 100644 --- a/apps/web/src/components/files/binaryPreview.worker.ts +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -52,6 +52,7 @@ function tarEntries(bytes: Uint8Array) { return Number.parseInt(value || "0", 8); }; let longPath: string | null = null; + let longLink: string | null = null; const globalPax: Record = {}; let localPax: Record = {}; let offset = 0; @@ -64,7 +65,7 @@ function tarEntries(bytes: Uint8Array) { checksum += index >= 148 && index < 156 ? 32 : bytes[offset + index]!; if (checksum !== number(offset + 148, 8)) throw new Error("The TAR header is damaged."); const type = text(offset + 156, 1); - const metadata = type === "L" || type === "x" || type === "g"; + const metadata = type === "L" || type === "K" || type === "x" || type === "g"; const attributes: Record = metadata ? {} : { ...globalPax, ...localPax }; const size = attributes.size ? Number(attributes.size) : number(offset + 124, 12); if (attributes.size && !/^\d+$/.test(attributes.size)) @@ -75,7 +76,9 @@ function tarEntries(bytes: Uint8Array) { : ""; const path = attributes.path || longPath || [prefix, text(offset, 100)].filter(Boolean).join("/"); + const linkpath = attributes.linkpath ?? longLink ?? text(offset + 157, 100); if (!metadata) { + longLink = null; longPath = null; localPax = {}; } @@ -89,6 +92,7 @@ function tarEntries(bytes: Uint8Array) { throw new Error("An archive entry is too large or damaged."); const content = bytes.subarray(offset + 512, offset + 512 + size); if (type === "L") longPath = new TextDecoder().decode(content).replace(/\0.*$/s, ""); + else if (type === "K") longLink = new TextDecoder().decode(content).replace(/\0.*$/s, ""); else if (type === "x" || type === "g") { if (size > 65536) throw new Error("Archive metadata is too large."); for (let cursor = 0; cursor < content.length;) { @@ -111,26 +115,38 @@ function tarEntries(bytes: Uint8Array) { if (equals < 1) throw new Error("The TAR metadata is damaged."); const key = record.slice(0, equals); const value = record.slice(equals + 1); - if (["path", "size", "mtime"].includes(key)) { + if (["path", "linkpath", "size", "mtime"].includes(key)) { if (type === "x") localPax[key] = value; - else if (value) globalPax[key] = value; + else if (value || key === "linkpath") globalPax[key] = value; else delete globalPax[key]; } cursor = end; } - } else if (type === "" || type === "0" || type === "5") { + } else if ( + type === "" || + type === "0" || + type === "5" || + // A hard link shares an earlier member's bytes; one without that member is skipped. + (type === "1" && linkpath && files.has(linkpath)) + ) { if (attributes.mtime && !/^[+-]?\d+(?:\.\d+)?$/.test(attributes.mtime)) throw new Error("The TAR metadata is damaged."); const modified = (attributes.mtime ? Number(attributes.mtime) : number(offset + 136, 12)) * 1000; const date = new Date(modified); + const linked = type === "1" ? files.get(linkpath) : undefined; entries.push({ path, - size, + size: linked?.length ?? size, directory: type === "5", modified: Number.isNaN(date.getTime()) ? null : date.toISOString(), }); - if (type !== "5") files.set(path, content.slice()); + if (type === "5") { + files.delete(path); + files.delete(path.replace(/\/+$/, "")); + } else files.set(path, linked ?? content.slice()); + } else { + files.delete(path); } offset += 512 + Math.ceil(size / 512) * 512; }