Skip to content

Commit 39df92f

Browse files
authored
fix: extract .tar.7z snap template archives correctly (#10003)
1 parent 7a0abca commit 39df92f

4 files changed

Lines changed: 133 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"app-builder-lib": patch
3+
---
4+
5+
fix: extract `.tar.7z` snap template archives through both compression layers. Since 26.15.0, default-config snap builds packed the template's inner tar as a single file instead of its contents (`desktop-init.sh` etc.), producing snaps that built successfully but failed at launch. The toolset cache directory name for `.tar.7z` archives also changes, so caches poisoned by the broken extraction are automatically re-fetched after upgrading.

packages/app-builder-lib/src/util/electronGet.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -215,21 +215,24 @@ export async function extractArchive(archive: string, dir: string) {
215215

216216
if (file.endsWith(".tar.gz") || file.endsWith(".tgz")) {
217217
await tar.extract({ file, cwd: tmpDir, strip: 1 })
218-
} else if (file.endsWith(".tar.xz") || file.endsWith(".txz")) {
219-
// node-tar cannot decompress xz, so use 7za to turn the .tar.xz into a .tar, then extract that tar.
218+
} else if (file.endsWith(".tar.xz") || file.endsWith(".txz") || file.endsWith(".tar.7z")) {
219+
// Compressed tarballs node-tar cannot decompress itself (xz, 7z): use 7za to strip the outer
220+
// compression layer into a .tar, then extract that tar.
221+
// Note: the .tar.7z check MUST stay ahead of the plain .7z branch below, otherwise only the outer
222+
// 7z layer is removed and the inner tar is left behind as-is (see https://github.com/electron-userland/electron-builder/issues/10002).
220223
const cmd7za = await getPath7za()
221-
const xzOutDir = `${tmpDir}.xz`
222-
await fs.rm(xzOutDir, { recursive: true, force: true })
223-
await fs.mkdir(xzOutDir, { recursive: true })
224+
const decompressOutDir = `${tmpDir}.decompress`
225+
await fs.rm(decompressOutDir, { recursive: true, force: true })
226+
await fs.mkdir(decompressOutDir, { recursive: true })
224227
try {
225-
await exec(cmd7za, ["x", "-bd", file, to7zaOutputSwitch(sanitizeDirPath(xzOutDir)), "-y"])
226-
const innerTar = (await fs.readdir(xzOutDir)).find(f => f.endsWith(".tar"))
228+
await exec(cmd7za, ["x", "-bd", file, to7zaOutputSwitch(sanitizeDirPath(decompressOutDir)), "-y"])
229+
const innerTar = (await fs.readdir(decompressOutDir)).find(f => f.endsWith(".tar"))
227230
if (innerTar == null) {
228-
throw new Error(`xz decompression of ${path.basename(file)} produced no .tar archive`)
231+
throw new Error(`decompression of ${path.basename(file)} produced no .tar archive`)
229232
}
230-
await tar.extract({ file: path.join(xzOutDir, innerTar), cwd: tmpDir, strip: 1 })
233+
await tar.extract({ file: path.join(decompressOutDir, innerTar), cwd: tmpDir, strip: 1 })
231234
} finally {
232-
await fs.rm(xzOutDir, { recursive: true, force: true })
235+
await fs.rm(decompressOutDir, { recursive: true, force: true })
233236
}
234237
} else if (file.endsWith(".zip")) {
235238
await extractZipStreaming(file, tmpDir)
@@ -600,7 +603,10 @@ export async function downloadBuilderToolset(options: {
600603
const baseUrl = getBinariesMirrorUrl(githubOrgRepo)
601604
const fullUrl = resolveBuilderBinaryUrl(releaseName, filenameWithExt, baseUrl, overrideUrl)
602605
const suffix = hashUrlSafe(fullUrl, 5)
603-
const folderName = `${filenameWithExt.replace(/\.(tar\.gz|tgz|tar\.xz|txz|zip|7z)$/, "")}-${suffix}`
606+
// tar.7z is listed before 7z so the full extension is stripped. Deliberate side effect: this changes the
607+
// extract-dir name for .tar.7z toolsets (e.g. the snap template) from "<name>.tar-<hash>" to "<name>-<hash>",
608+
// which busts caches poisoned by the broken .tar.7z extraction in 26.15.0-26.15.6 (issue #10002).
609+
const folderName = `${filenameWithExt.replace(/\.(tar\.gz|tgz|tar\.xz|txz|tar\.7z|zip|7z)$/, "")}-${suffix}`
604610
// releaseName is library input; enforce cache-dir containment (rejects traversal, clears taint into shell extraction)
605611
const cacheDir = await cacheDirectoryOverrideAllowed.value
606612
const extractDir = sanitizeDirPath(path.join(cacheDir, releaseName, folderName), cacheDir)

test/src/electronGetTest.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { exec } from "builder-util"
12
import { readFileSync } from "fs"
23
import * as fs from "fs/promises"
34
import * as http from "http"
@@ -347,6 +348,59 @@ describe("downloadBuilderToolset", { sequential: true }, () => {
347348
await server.close()
348349
}
349350
})
351+
352+
// Regression test for https://github.com/electron-userland/electron-builder/issues/10002
353+
// Snap template toolsets ship as .tar.7z; extractArchive routed them through the plain .7z
354+
// branch, leaving a single inner .tar in the toolset dir instead of the template contents,
355+
// so every default-config snap built with 26.15.0-26.15.6 silently failed at launch.
356+
// Also asserts the cache dir name now strips the full ".tar.7z" extension — a deliberate
357+
// rename that busts caches poisoned by the broken extraction (the old "<name>.tar-<hash>"
358+
// dirs pass the cache-complete check and would otherwise never be re-extracted).
359+
test("toolset .tar.7z is extracted through both layers and gets a cache-busting dir name (#10002)", { timeout: 120_000 }, async ({ expect, tmpDir }) => {
360+
// Resolve 7za before stubbing the mirror env vars: extractArchive needs it, and resolving it
361+
// afterwards would try to download the 7zip toolset from our fixture server.
362+
const { getPath7za } = await import("app-builder-lib/src/toolsets/7zip")
363+
const cmd7za = await getPath7za()
364+
365+
// Craft a fixture mirroring snap-template-electron-*.tar.7z: a 7z layer around a tar with
366+
// "./"-prefixed entries and a mode-755 desktop-init.sh at the root.
367+
const fixtureDir = await tmpDir.createTempDir()
368+
const contentDir = path.join(fixtureDir, "content")
369+
await fs.mkdir(path.join(contentDir, "usr", "share"), { recursive: true })
370+
await fs.writeFile(path.join(contentDir, "desktop-init.sh"), "#!/bin/bash\ntrue\n", { mode: 0o755 })
371+
await fs.writeFile(path.join(contentDir, "usr", "share", "marker.txt"), "ok")
372+
const innerTar = path.join(fixtureDir, "snap-template-test-amd64.tar")
373+
// explicit "./"-prefixed entries so node-tar stores "./"-prefixed names like the real template tars
374+
await tar.create(
375+
{ file: innerTar, cwd: contentDir },
376+
(await fs.readdir(contentDir)).map(e => `./${e}`)
377+
)
378+
const servedArchive = path.join(fixtureDir, "snap-template-test-amd64.tar.7z")
379+
await exec(cmd7za, ["a", "-t7z", servedArchive, innerTar])
380+
381+
const freshTestCache = await tmpDir.createTempDir()
382+
vi.stubEnv("ELECTRON_BUILDER_CACHE", freshTestCache)
383+
const server = await startArtifactServer(servedArchive)
384+
vi.stubEnv("ELECTRON_BUILDER_BINARIES_MIRROR", `http://127.0.0.1:${server.port}/`)
385+
386+
try {
387+
const result = await downloadBuilderToolset({ releaseName: "snap-template-test", filenameWithExt: "snap-template-test-amd64.tar.7z" })
388+
389+
// both layers extracted: template files at the toolset root, no stray inner .tar
390+
const entries = await fs.readdir(result)
391+
expect(entries).toContain("desktop-init.sh")
392+
expect(entries).toContain("usr")
393+
expect(entries.filter(e => e.endsWith(".tar"))).toEqual([])
394+
expect(await fs.readFile(path.join(result, "usr", "share", "marker.txt"), "utf-8")).toBe("ok")
395+
396+
// cache-busting dir name: full ".tar.7z" stripped (26.15.x stripped only ".7z",
397+
// producing "<name>.tar-<hash>" dirs that hold the broken single-tar extraction)
398+
expect(path.basename(result)).toMatch(/^snap-template-test-amd64-/)
399+
expect(path.basename(result)).not.toContain(".tar")
400+
} finally {
401+
await server.close()
402+
}
403+
})
350404
})
351405

352406
// ─── downloadBuilderToolset: filenameWithExt validation ──────────────────────

test/src/extractArchiveTest.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { moveDirAtomic } from "builder-util"
1+
import { exec, moveDirAtomic } from "builder-util"
22
import { extractArchive, isSafeExtractPath } from "app-builder-lib/src/util/electronGet"
3+
import { getPath7za } from "app-builder-lib/src/toolsets/7zip"
34
import * as fs from "fs/promises"
45
import * as os from "os"
56
import * as path from "path"
7+
import * as tar from "tar"
68
import { afterEach, describe, test, vi } from "vitest"
79

810
afterEach(() => {
@@ -151,6 +153,60 @@ describe("extractArchive ZIP security guards", () => {
151153
})
152154
})
153155

156+
// ─── extractArchive .tar.7z (snap template layout) ────────────────────────────
157+
158+
/**
159+
* Crafts a .tar.7z archive that mirrors the snap-template-electron-*.tar.7z assets from
160+
* electron-builder-binaries: a 7z compression layer around a tar whose entries are all
161+
* "./"-prefixed, containing a mode-755 desktop-init.sh at the root plus a nested tree.
162+
*/
163+
async function createSnapTemplateLikeTar7z(workDir: string, archiveName: string): Promise<string> {
164+
const contentDir = path.join(workDir, "content")
165+
await fs.mkdir(path.join(contentDir, "usr", "share"), { recursive: true })
166+
await fs.writeFile(path.join(contentDir, "desktop-init.sh"), "#!/bin/bash\ntrue\n", { mode: 0o755 })
167+
await fs.writeFile(path.join(contentDir, "usr", "share", "marker.txt"), "ok")
168+
169+
// Explicit "./"-prefixed entries make node-tar store "./"-prefixed names, matching the real
170+
// template tars, so tar.extract({ strip: 1 }) is what flattens them into the extraction root.
171+
const innerTar = path.join(workDir, archiveName.replace(/\.7z$/, ""))
172+
const entries = (await fs.readdir(contentDir)).map(e => `./${e}`)
173+
await tar.create({ file: innerTar, cwd: contentDir }, entries)
174+
175+
const archivePath = path.join(workDir, archiveName)
176+
await exec(await getPath7za(), ["a", "-t7z", archivePath, innerTar])
177+
return archivePath
178+
}
179+
180+
describe("extractArchive .tar.7z", () => {
181+
// Regression test for https://github.com/electron-userland/electron-builder/issues/10002:
182+
// .tar.7z fell through to the plain .7z branch, so only the outer 7z layer was removed and the
183+
// extraction dir contained a single inner .tar instead of the template contents. Snap builds
184+
// then packed that tar as-is and the resulting snaps failed at launch (desktop-init.sh missing).
185+
test("extracts both layers of a .tar.7z, not just the outer 7z", async ({ expect, tmpDir }) => {
186+
const tmpDirPath = await tmpDir.createTempDir()
187+
const archivePath = await createSnapTemplateLikeTar7z(tmpDirPath, "snap-template-test-amd64.tar.7z")
188+
const extractDir = path.join(tmpDirPath, "out")
189+
190+
await extractArchive(archivePath, extractDir)
191+
192+
const entries = await fs.readdir(extractDir)
193+
// template contents must land at the extraction root...
194+
expect(entries).toContain("desktop-init.sh")
195+
expect(entries).toContain("usr")
196+
// ...and the inner tar must not be left behind as a file (the 26.15.x symptom)
197+
expect(entries.filter(e => e.endsWith(".tar"))).toEqual([])
198+
199+
expect(await fs.readFile(path.join(extractDir, "desktop-init.sh"), "utf-8")).toBe("#!/bin/bash\ntrue\n")
200+
expect(await fs.readFile(path.join(extractDir, "usr", "share", "marker.txt"), "utf-8")).toBe("ok")
201+
202+
if (process.platform !== "win32") {
203+
// the template's launch scripts must stay executable, otherwise the snap dies at startup
204+
const stat = await fs.stat(path.join(extractDir, "desktop-init.sh"))
205+
expect(stat.mode & 0o100).toBeTruthy()
206+
}
207+
})
208+
})
209+
154210
// ─── isSafeExtractPath ────────────────────────────────────────────────────────
155211

156212
describe("isSafeExtractPath", () => {

0 commit comments

Comments
 (0)