From 056776418b58c13f035d8e456ef77c7adc5e854d Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Wed, 11 Nov 2026 16:34:35 +0530 Subject: [PATCH 1/4] fix(web): show TAR hard links in archive previews --- .../AttachmentFilePreview.browser.test.tsx | 46 ++++++++++++++++++- .../components/files/binaryPreview.worker.ts | 19 ++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 2835e4ab678b3..9d497bfa9ffb1 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -84,13 +84,20 @@ import type { ReactElement } from "react"; 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 @@ -122,6 +129,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)); @@ -153,6 +169,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"); @@ -163,6 +187,26 @@ 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"); + } + for (const chunks of [ + [block("alias.txt", encode(""), "1", 0, "missing.txt")], + [block("self.txt", encode(""), "1", 0, "self.txt")], + [block("alias.txt", encode(""), "1", 0, "later.txt"), block("later.txt", encode("hello"))], + [block("a.txt", encode(""), "1", 0, "b.txt"), block("b.txt", encode(""), "1", 0, "a.txt")], + [block("folder/", encode(""), "5"), block("alias.txt", encode(""), "1", 0, "folder/")], + ]) { + const archive = new Uint8Array(await new Blob(chunks).arrayBuffer()); + expect( + await request({ type: "open", kind: "archive", name: "link.tar", bytes: archive.buffer }), + ).toEqual({ + type: "error", + message: "A TAR hard link does not refer to an earlier file in this archive.", + }); + } } 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..a1fc856a90f8c 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,31 @@ 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 delete globalPax[key]; } cursor = end; } - } else if (type === "" || type === "0" || type === "5") { + } else if (type === "" || type === "0" || type === "1" || type === "5") { 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); + // TAR hard links name an earlier member; sharing its bytes also resolves chains in one pass. + const linked = type === "1" ? files.get(linkpath) : undefined; + if (type === "1" && !linked) + throw new Error("A TAR hard link does not refer to an earlier file in this archive."); 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); + else files.set(path, linked ?? content.slice()); } offset += 512 + Math.ceil(size / 512) * 512; } From 37d427afc30d8342b1beb77ac540fedb3494b404 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Wed, 11 Nov 2026 17:40:52 +0530 Subject: [PATCH 2/4] fix(web): invalidate replaced TAR hard-link targets --- .../files/AttachmentFilePreview.browser.test.tsx | 10 ++++++++++ apps/web/src/components/files/binaryPreview.worker.ts | 8 ++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 9d497bfa9ffb1..c9eed363d7f2b 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -198,6 +198,16 @@ describe("bounded binary previews", () => { [block("alias.txt", encode(""), "1", 0, "later.txt"), block("later.txt", encode("hello"))], [block("a.txt", encode(""), "1", 0, "b.txt"), block("b.txt", encode(""), "1", 0, "a.txt")], [block("folder/", encode(""), "5"), block("alias.txt", encode(""), "1", 0, "folder/")], + [ + block("replaced.txt", encode("stale")), + block("replaced.txt", encode(""), "2", 0, "elsewhere.txt"), + block("alias.txt", encode(""), "1", 0, "replaced.txt"), + ], + [ + block("folder", encode("stale")), + block("folder/", encode(""), "5"), + block("alias.txt", encode(""), "1", 0, "folder"), + ], ]) { const archive = new Uint8Array(await new Blob(chunks).arrayBuffer()); expect( diff --git a/apps/web/src/components/files/binaryPreview.worker.ts b/apps/web/src/components/files/binaryPreview.worker.ts index a1fc856a90f8c..e548ba5f46af5 100644 --- a/apps/web/src/components/files/binaryPreview.worker.ts +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -138,8 +138,12 @@ function tarEntries(bytes: Uint8Array) { directory: type === "5", modified: Number.isNaN(date.getTime()) ? null : date.toISOString(), }); - if (type === "5") files.delete(path); - else files.set(path, linked ?? 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; } From f6ff3a8d8f72a5ec8dc5067efc738e00e2daca3c Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Wed, 11 Nov 2026 17:48:35 +0530 Subject: [PATCH 3/4] fix(web): honor empty PAX hard-link targets --- .../files/AttachmentFilePreview.browser.test.tsx | 11 +++++++++++ apps/web/src/components/files/binaryPreview.worker.ts | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index c9eed363d7f2b..00915f9db82e9 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -198,6 +198,17 @@ describe("bounded binary previews", () => { [block("alias.txt", encode(""), "1", 0, "later.txt"), block("later.txt", encode("hello"))], [block("a.txt", encode(""), "1", 0, "b.txt"), block("b.txt", encode(""), "1", 0, "a.txt")], [block("folder/", encode(""), "5"), block("alias.txt", encode(""), "1", 0, "folder/")], + [ + block("original.txt", encode("hello")), + block("Local", attributes({ linkpath: "" }), "x"), + block("alias.txt", encode(""), "1", 0, "original.txt"), + ], + [ + block("original.txt", encode("hello")), + block("LongLink", encode("original.txt\0"), "K"), + block("Global", attributes({ linkpath: "" }), "g"), + block("alias.txt", encode(""), "1", 0, "original.txt"), + ], [ block("replaced.txt", encode("stale")), block("replaced.txt", encode(""), "2", 0, "elsewhere.txt"), diff --git a/apps/web/src/components/files/binaryPreview.worker.ts b/apps/web/src/components/files/binaryPreview.worker.ts index e548ba5f46af5..4105314a652e3 100644 --- a/apps/web/src/components/files/binaryPreview.worker.ts +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -76,7 +76,7 @@ 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); + const linkpath = attributes.linkpath ?? longLink ?? text(offset + 157, 100); if (!metadata) { longLink = null; longPath = null; @@ -117,7 +117,7 @@ function tarEntries(bytes: Uint8Array) { const value = record.slice(equals + 1); 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; @@ -129,7 +129,7 @@ function tarEntries(bytes: Uint8Array) { (attributes.mtime ? Number(attributes.mtime) : number(offset + 136, 12)) * 1000; const date = new Date(modified); // TAR hard links name an earlier member; sharing its bytes also resolves chains in one pass. - const linked = type === "1" ? files.get(linkpath) : undefined; + const linked = type === "1" && linkpath ? files.get(linkpath) : undefined; if (type === "1" && !linked) throw new Error("A TAR hard link does not refer to an earlier file in this archive."); entries.push({ From 207533163f8920bda7c55a83fb39007b5724045f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 19:08:22 +0000 Subject: [PATCH 4/4] fix(web): skip unresolvable TAR hard links instead of failing the archive A hard link whose target is missing, later, a folder, or replaced no longer turns an archive that previewed on main into an error; it is left out of the listing like other unsupported members. Co-Authored-By: Claude Opus 5.5 --- .../AttachmentFilePreview.browser.test.tsx | 63 +++++++++---------- .../components/files/binaryPreview.worker.ts | 13 ++-- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index cbb7a87d7d0e1..88a75195d2b8c 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -193,42 +193,37 @@ describe("bounded binary previews", () => { if (linked.type !== "entry") throw new Error("Expected hard link entry"); expect(new TextDecoder().decode(linked.bytes)).toBe("hello world"); } - for (const chunks of [ - [block("alias.txt", encode(""), "1", 0, "missing.txt")], - [block("self.txt", encode(""), "1", 0, "self.txt")], - [block("alias.txt", encode(""), "1", 0, "later.txt"), block("later.txt", encode("hello"))], - [block("a.txt", encode(""), "1", 0, "b.txt"), block("b.txt", encode(""), "1", 0, "a.txt")], - [block("folder/", encode(""), "5"), block("alias.txt", encode(""), "1", 0, "folder/")], - [ - block("original.txt", encode("hello")), - block("Local", attributes({ linkpath: "" }), "x"), - block("alias.txt", encode(""), "1", 0, "original.txt"), - ], - [ - block("original.txt", encode("hello")), - block("LongLink", encode("original.txt\0"), "K"), - block("Global", attributes({ linkpath: "" }), "g"), - block("alias.txt", encode(""), "1", 0, "original.txt"), - ], - [ + 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("alias.txt", encode(""), "1", 0, "replaced.txt"), - ], - [ - block("folder", encode("stale")), - block("folder/", encode(""), "5"), - block("alias.txt", encode(""), "1", 0, "folder"), - ], - ]) { - const archive = new Uint8Array(await new Blob(chunks).arrayBuffer()); - expect( - await request({ type: "open", kind: "archive", name: "link.tar", bytes: archive.buffer }), - ).toEqual({ - type: "error", - message: "A TAR hard link does not refer to an earlier file in this archive.", - }); - } + 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 4105314a652e3..124a1261e39e3 100644 --- a/apps/web/src/components/files/binaryPreview.worker.ts +++ b/apps/web/src/components/files/binaryPreview.worker.ts @@ -122,16 +122,19 @@ function tarEntries(bytes: Uint8Array) { } cursor = end; } - } else if (type === "" || type === "0" || type === "1" || 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); - // TAR hard links name an earlier member; sharing its bytes also resolves chains in one pass. - const linked = type === "1" && linkpath ? files.get(linkpath) : undefined; - if (type === "1" && !linked) - throw new Error("A TAR hard link does not refer to an earlier file in this archive."); + const linked = type === "1" ? files.get(linkpath) : undefined; entries.push({ path, size: linked?.length ?? size,